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

TCP echo server · 0.0.26

Первый сетевой срез использует IPv4 127.0.0.1, блокирующие операции и одного обслуживаемого клиента. Приложение написано на Liza; небольшой C runtime соединяет его с POSIX sockets или Winsock. Проверка сетевого runtime и машинного кода формальными экспериментами V0–V4 не заявляется.

Запуск

python -m liza build examples/apps/liza_echo.liza -O 2 -o build/liza-echo
./build/liza-echo

По умолчанию сервер слушает 127.0.0.1:9000 и последовательно обслуживает клиентов до остановки процесса. Он выводит фактически выбранный порт и LF в stdout. --ephemeral выбирает свободный порт ОС. --once тоже выбирает свободный порт, но обслуживает только одного клиента и затем нормально закрывает listener. На Windows исполняемый файл получает расширение .exe.

Клиент для отдельного терминала:

import socket
with socket.create_connection(("127.0.0.1", 9000), timeout=5) as stream:
    payload = b"Liza\x00\xff"
    stream.sendall(payload)
    stream.shutdown(socket.SHUT_WR)
    received = bytearray()
    while True:
        chunk = stream.recv(2048)
        if not chunk:
            break
        received.extend(chunk)
    assert bytes(received) == payload

TCP — поток байтов: границы вызовов send, read и сообщений не совпадают. Приложение не интерпретирует UTF-8 и возвращает любые байты, включая NUL.

Типы и операции

net.Listener и net.Stream — разные непрозрачные move-only ресурсы. Их нельзя создать из числа, скопировать или потерять на нормальном пути. Они используют существующие ownership analysis и Result. Отдельный Address пока не вводится: адрес всегда loopback, порт имеет тип u16. Порт 0 означает выбор свободного порта ОС, а не преобразование адресной строки.

Все операции имеют эффект io. Он включает потенциально неограниченное ожидание. Эффект block, deadlines, readiness, IPv6, DNS, TLS и конкурентность в этот срез не входят. ОС может выделять память для сокетов; контракт отсутствия аллокаций относится к прикладным буферам, а не ко всему сетевому стеку.

Вызов Результат Владение
net.listen(port) Result<net.Listener,NetError> Новый listener при успехе
net.local_port(listener) Result<u16,NetError> Временное заимствование
net.accept(listener) Result<net.Stream,NetError> Listener сохраняется; новый stream при успехе
net.read(stream, move buffer) Линейное произведение буфера и Result<u32,NetError> Stream заимствуется, буфер возвращается безусловно
net.write(stream, data, start, count) Result<u32,NetError> Stream и View<u8> заимствуются
net.close_stream(move stream) Result<u32,NetError> Потребляет stream даже при ошибке
net.close_listener(move listener) Result<u32,NetError> Потребляет listener даже при ошибке

Заимствование сокета ограничено вызовом. Не требуется создавать новый дескриптор сокета после каждого чтения или записи. Перемещение сокета между обычными функциями по-прежнему требует move.

Чтение в существующее размещение

(next, received) = net.read(stream, move buffer)
buffer = move next

Буфер имеет тип memory.Buffer<u8>. Он уже полностью инициализирован обычным memory.alloc<u8>. read пишет непосредственно в его размещение и сохраняет его адрес, длину и зависимость от региона. Объём памяти не меняется. Передать буфер при живом readonly view нельзя.

  • Ok(n), где n > 0: первые n байтов заменены принятыми данными; хвост сохраняет прежнее содержимое.
  • Ok(0): peer завершил отправку, EOF. Это не требует немедленного закрытия направления отправки сервером.
  • Err(error): данных этим вызовом не принято; буфер остаётся доступным.
  • Нулевая длина буфера возвращает InvalidRange, чтобы Ok(0) однозначно означал EOF.

Один вызов читает не более min(buffer.len, INT_MAX) байтов. Ошибки сети не вызывают language trap. При наличии данных может быть возвращён короткий read.

Запись и частичный внешний результат

net.write(stream, data, start, count) отправляет диапазон readonly view. Проверки start <= data.len и count <= data.len-start выполняются без переполнения; нарушение возвращает InvalidRange. Пустой диапазон даёт Ok(0). Для непустого диапазона успешный результат положителен и не превышает count. Если системный send неожиданно возвращает ноль для непустого диапазона, runtime возвращает Err(NetError.Disconnected), а не Ok(0). Поэтому каждая успешная итерация цикла досылки строго увеличивает смещение; ошибка завершает цикл. Это гарантия прогресса между завершёнными вызовами, не ограничение времени ожидания внутри блокирующего send.

Выполняется один send, не более INT_MAX байтов. Ok(n) означает, что локальный сетевой стек принял n байтов, не что приложение на другом конце их прочитало. Остаток отправляет приложение. Err не отменяет данные предыдущих успешных вызовов и не делает запись сообщения атомарной. Сигнал SIGPIPE подавляется для сетевой записи, а разрыв отражается обычной ошибкой.

Все аргументы вычисляются до вызова, по существующим правилам Liza. Никакой особой ленивой оценки нового сетевого API нет.

Ошибки и закрытие

NetError: AddressInUse, PermissionDenied, Interrupted, WouldBlock, Disconnected, InvalidRange, Other. Отображение кодов ОС может различаться; Other сохраняет ожидаемую ошибку без обещания точной переносимости errno. Interrupted и WouldBlock отделены от EOF. В первом echo-приложении они завершают текущую операцию с ошибкой; скрытого retry-цикла нет.

На нормальном пути сокеты закрываются явно. close потребляет ресурс и при ошибке: повторное закрытие того же дескриптора не допускается. Runtime не повторяет POSIX close после EINTR, поскольку повторный вызов может затронуть переиспользованный дескриптор. Ошибка закрытия не обещает универсального состояния внешнего ресурса на всех ОС. Trap и внешнее завершение процесса не обещают выполнения пользовательского cleanup.

На Windows каждому успешно созданному сокету соответствует собственная ссылка WSAStartup/WSACleanup. Ошибка создания или accept освобождает промежуточные ресурсы. Listener использует SO_EXCLUSIVEADDRUSE; SO_REUSEADDR не включается. См. контракт Winsock.

Жизненный цикл echo

examples/apps/liza_echo.liza
// One active client; a 2048-byte region buffer reused for every read.
fn exchange(stream:net.Stream, initial:memory.Buffer<u8>)->u32:io {
    var buffer=move initial
    var running=true
    var status:u32=0
    while running {
        (next,received)=net.read(stream,move buffer)
        buffer=move next
        match received {
            Err(error)=>{status=3;running=false}
            Ok(count)=>{
                if count==0 {running=false}
                else {
                    data=memory.view(buffer)
                    var offset:u32=0
                    while offset<count && running {
                        match net.write(stream,data,offset,count-offset) {
                            Err(error)=>{status=3;running=false}
                            Ok(written)=>{offset=offset+written}
                        }
                    }
                }
            }
        }
    }
    released=memory.release(move buffer)
    match net.close_stream(move stream) {Ok(done)=>{} Err(error)=>{status=3}}
    return status
}
fn serve(stream:net.Stream)->u32:io,alloc {
    match memory.create(2048) {
        Err(error)=>{closed=net.close_stream(move stream);return 3}
        Ok(region)=>{
            match memory.alloc<u8>(region,2048,0) {
                Err(error)=>{
                    freed=memory.close(move region)
                    closed=net.close_stream(move stream)
                    return 3
                }
                Ok(buffer)=>{
                    status=exchange(move stream,move buffer)
                    freed=memory.close(move region)
                    return status
                }
            }
        }
    }
}
fn main()->u32:io,alloc {
    var once=false
    var port:u16=9000
    if cli.count()!=1 {
        if cli.count()!=2 {message=console.err("Usage: liza-echo [--once | --ephemeral]\n");return 2}
        match cli.arg(1) {
            None=>{return 2}
            Some(arg)=>{
                if !cli.equals(arg,"--once") && !cli.equals(arg,"--ephemeral") {message=console.err("Usage: liza-echo [--once | --ephemeral]\n");return 2}
                once=cli.equals(arg,"--once")
                port=0
            }
        }
    }
    match net.listen(port) {
        Err(error)=>{message=console.err("Cannot listen on loopback\n");return 3}
        Ok(listener)=>{
            var status:u32=0
            var running=true
            match net.local_port(listener) {
                Err(error)=>{status=3;running=false}
                Ok(actual)=>{
                    match console.print_u64(u64(actual)) {Ok(done)=>{} Err(error)=>{status=3;running=false}}
                    match console.write("\n") {Ok(done)=>{} Err(error)=>{status=3;running=false}}
                }
            }
            while running {
                match net.accept(listener) {
                    Err(error)=>{status=3;running=false}
                    Ok(stream)=>{
                        result=serve(move stream)
                        if result!=0 {
                            match console.err("Connection I/O failed\n") {Ok(done)=>{} Err(error)=>{running=false}}
                            status=3
                        }
                        if once {running=false}
                    }
                }
            }
            match net.close_listener(move listener) {Ok(done)=>{} Err(error)=>{status=3}}
            return status
        }
    }
}
  1. main создаёт listener и сообщает порт. Ошибка вывода предотвращает accept.
  2. accept создаёт один stream. Listener остаётся владельцем слушающего сокета.
  3. serve создаёт регион на 2048 байтов и один инициализированный буфер.
  4. exchange читает в этот же буфер. Линейное извлечение возвращает владение буфером независимо от результата I/O.
  5. При ненулевом read создаётся lexical readonly view. Цикл записи двигает смещение на фактически принятое число байтов.
  6. Scope view заканчивается до следующего read.
  7. При EOF или ошибке буфер освобождается логически, stream закрывается, затем serve закрывает регион. Следующий клиент получает новый регион.

Память приложения ограничена одним 2048-байтовым буфером плюс дескрипторы и фиксированные метаданные. Аллокации происходят на соединение, не на сообщение. Одновременно обслуживается один stream. listen запрашивает backlog 1, но фактическая очередь и память сетевого стека управляются ОС; это не строгий лимит числа ожидающих TCP-соединений. Медленный клиент блокирует остальных.

Коды выхода: 0 — успешный --once; 2 — неверные аргументы; 3 — ошибка сети, памяти или обязательного вывода/закрытия. При ошибке клиента постоянный сервер сообщает её в stderr и продолжает принимать клиентов. Управляемое завершение постоянного сервера по сигналу — отдельный будущий этап.

Проверки и граница результата

tests/test_net.py проверяет O0/O2 через настоящий loopback TCP: пустой поток, бинарные данные больше буфера, half-close, reset и последовательных клиентов. Подмена только границы ОС воспроизводит короткие записи, ошибки чтения, записи и закрытия, проверяя количество и порядок закрываемых сокетов. Отдельная подмена send в настоящем runtime проверяет нулевой, короткий и полный результат при O0/O2; пустой диапазон не вызывает send. Reset остаётся обязательным тестом на всех трёх платформах.

Статические отрицательные тесты проверяют copy, потерю ресурса, повторное закрытие, эффекты, несовместимые типы сокетов, живой view и преждевременное закрытие региона после read. Структурная проверка адаптера LLVM исключает аллокацию и копирование содержимого буфера в этом адаптере; это не общий сертификат стоимости произвольных сетевых программ.

Набор включён в Linux/macOS compiler tests и Windows acceptance. Завершённость переносимой версии подтверждается результатами CI конкретного коммита. Verification Bundle развивается отдельно; сетевые операции не входят в область доказательств V0–V4.