/
Поиск
RU

Сеть: Dial/Listen, линейные сокеты, детерминированный симулятор

Сокет — это дескриптор, которым операционная система обозначает один конец сетевого разговора. Программы читают и пишут его почти как файл, через файловый дескриптор. Большинство программ пользуется одним из двух протоколов. TCP даёт надёжный упорядоченный поток байтов поверх соединения: одна сторона слушает порт (16-битное число, которое обозначает службу на машине, например 443 для HTTPS), другая подключается, и слушающая сторона принимает соединение. UDP отправляет независимые датаграммы — отдельные пакеты, которые могут потеряться, продублироваться или прийти не по порядку, и никакого соединения при этом нет. Сокет ведёт себя как файл, но с двумя осложнениями: на другом конце сидит собеседник, который может вести себя плохо без всякого участия вашей программы, а данные приходят такими кусками, какими их нарежет сеть. std::net обращается с сокетами так же, как глава о файловой системе с файлами. Чтобы подключиться или начать слушать, нужна капабилити, а каждое соединение — это ресурс, который нельзя упустить. На этой странице разобраны разделение Dial и Listen, цикл работы с потоком байтов, датаграммы, ожидание готовности и реактор, а также симулятор, чьи короткие чтения по зерну намеренно ломают наивные читающие функции.

Dial и Listen: два вопроса аудита

Файловая система — одна капабилити. Сеть — две: Dial для исходящих соединений («может ли этот код звонить наружу?») и Listen для входящих («может ли этот код принимать соединения и стать службой?»). Разделение проходит не по протоколам: и TCP, и UDP встречаются с обеих сторон. Оно проходит по модели угроз и совпадает с тем, о чём спрашивает рецензент, читая манифест пакета. Библиотека DNS-клиента никогда не должна иметь возможности занять слушающий порт. Обработчик запросов никогда не должен иметь возможности открыть исходящее соединение. Два слова в [caps] allowed отвечают на оба вопроса без чтения кода.

use std::net;

fn serve_one(listen: &Listen) -> Result[u64, (NetErr, OsErr)] !{io, block} {
    let mut lst = listen.bind("svc")?;
    let mut conn = match lst.try_accept() {
        Ok(Some(c)) => c,
        Ok(None) => { lst.close(); return Ok(0u64); }
        Err(e) => { lst.close(); return Err(e); }
    };
    let mut buf = [0u8; 8];
    let result = match conn.try_read(&mut buf) {
        Ok(Some(n)) => Ok(widen[u64](n)),
        Ok(None) => Ok(0u64),
        Err(e) => Err(e),
    };
    let _c = conn.close();
    lst.close();
    result
}

test nothing_listens_yet (mut sim: Sim) {
    match sim.dial.connect("svc-not-yet-bound") {
        Ok(c) => { let _x = c.close(); assert false; }
        Err((e, _)) => { assert e == NetErr::Refused; }
    }
}

TCP и UDP отвечают на один и тот же вопрос аудита («разговаривать по сети»), поэтому делят одни и те же две капабилити и отличаются только методами. Транспорт выбирается по строке адреса: "host:port" — это TCP, собеседник по IPv6 записывается как "[::1]:53", а префикс unix: выбирает Unix-сокет в тех же вызовах connect и bind. Если имя разрешается в несколько адресов, они перебираются в порядке, который дал резолвер, а сам список можно получить через dial.resolve(host, &mut out).

Каждая сетевая операция, которая может не удаться, отвечает Result[T, (NetErr, OsErr)]. Ветвитесь по NetErr (Refused, Reset, Denied, WouldBlock, Invalid и так далее), а OsErr, в котором хранится собственная идентичность ошибки операционной системы, выводите в сообщение. «Пока ничего» — никогда не ошибка: операция try_ отвечает Ok(None), когда делать нечего, а ограниченное ожидание по истечении отвечает Ok(None) или Ok(false).

Есть и третья сетевая капабилити, и причина её существования — тоже довод аудита. sys.ping отправляет непривилегированные эхо-запросы ICMP: ping.send(addr) принимает только литерал IPv4, никогда не имя, а ping.recv(timeout_ms) отвечает Ok(Some(addr)) с адресом, который ответил, или Ok(None), когда ожидание истекло. От Dial она отделена потому, что умение прочесать сеть не вытекает из умения устанавливать исходящие соединения. Dial дотягивается до хостов, которые вы знаете; Ping находит те, которых вы не знаете. Обеим операциям нужен изменяемый корень, fn main(mut sys: Sys).

Conn — линейный ресурс

Каждый Conn, Listener, UdpSock и Poller должен быть израсходован ровно один раз на каждом пути, и если забыть об одном из них, получится та же ошибка компиляции, что и для незакрытого File. Вот полный обмен через петлевой интерфейс с детерминированным симулятором, который используется на всей этой странице:

use std::net;

test loopback_echo (mut sim: Sim) {
    region r {
        let msg = alloc in r [75u8, 79u8, 76u8, 65u8];  // "KOLA"
        let mut lst = sim.listen.bind("svc").unwrap();
        let mut client = sim.dial.connect("svc").unwrap();
        let mut server = lst.try_accept().unwrap().unwrap();

        let _s = net::send_all(&mut client, msg);
        let buf: &r mut Vec[u8] = alloc in r mut Vec::new();
        match net::recv_exact(&mut server, buf, 4usize, 10u64) {
            Ok(ok) => { assert ok; assert buf == msg; }
            Err(_) => { assert false; }
        }

        let _c1 = client.close();
        let _c2 = server.close();
        lst.close();
    }
}

Соединение, отданное рабочему потоку, перемещается; между потоками его никогда не заимствуют (Потоки и каналы). Общей памяти, в которой два потока могли бы бороться за один сокет, нет.

try_accept никогда не ждёт. Если ожидающих соединений нет, он отвечает Ok(None), так что настоящий сервер крутит его в цикле или регистрирует слушающий сокет в Poller. Тест выше может развернуть первый же try_accept через unwrap, потому что симулятор доставляет подключение раньше приёма. Не переносите этот приём в код, который работает с настоящими сокетами.

Цикл работы с потоком байтов: send_all, recv_exact и конец потока

TCP доставляет поток байтов, а не последовательность сообщений. Одна запись (write) на одной стороне — не одно чтение на другой, и один write может принять меньше байтов, чем вы предложили. net::send_all(&mut c, bytes) повторяет запись, пока не уйдут все байты. net::recv_exact(&mut c, out, want, timeout_ms) ждёт готовности и читает, пока не наберёт ровно want байтов; он отвечает Ok(true), когда набрал, Ok(false), если собеседник закрыл соединение раньше, и Err с WouldBlock, если истекло одно из ожиданий. Третий элемент — конец потока: ответ Ok(Some(0)) от try_read означает, что собеседник ушёл, и это обычное значение:

test peer_close_is_eof (mut sim: Sim) {
    let mut lst = sim.listen.bind("svc").unwrap();
    let mut client = sim.dial.connect("svc").unwrap();
    let mut server = lst.try_accept().unwrap().unwrap();
    let _c = client.close();
    let mut buf = [0u8; 8];
    match server.try_read(&mut buf) {
        Ok(Some(n)) => { assert n == 0usize; }
        Ok(None) => { assert false; }
        Err(_) => { assert false; }
    }
    let _s = server.close();
    lst.close();
}

Поведение собеседника — недоверенный ввод, поэтому ничто из того, что он делает (отказывает, сбрасывает соединение, закрывает его раньше времени, шлёт по байту за раз), не вызывает в вашем процессе трап. Это всегда Result или отличимый конец потока. Язык требует того же и от вашего собственного кода: параметр с пометкой untrusted не может появляться в контракте, потому что req — утверждение о вызывающем, у байтов из сети вызывающего нет, а трап на них был бы удалённым отказом в обслуживании.

c.shutdown_write() закрывает только направление отправки: собеседник прочтёт байты из вашей очереди, а затем конец потока, а вы по-прежнему можете читать его ответ. close при этом всё равно остаётся за вами.

Ограничение стороны записи

send_all ждёт столько, сколько угодно собеседнику. Собеседник, который перестал читать, заполняет своё окно приёма, затем ваш буфер отправки, и цикл после этого ждёт бесконечно. Зависания при записи никто не ожидает, поэтому его легко пропустить в клиенте, где все чтения аккуратно ограничены.

net::send_all_timeout(c, bytes, ms) ограничивает каждое ожидание того, что сокет примет ещё данные, — тот же контракт, что у recv_exact на стороне чтения. Это не дедлайн для всего вызова: собеседник, который продолжает забирать данные, пусть и медленно, поддерживает его в живых. Err с WouldBlock означает, что одно ожидание прошло, а ничего не было принято. Для одиночной записи есть c.write_timeout(bytes, ms), который отвечает Ok(None), если за отведённое время ничего не удалось поставить в очередь.

use std::net;

pub fn request(c: &mut Conn, msg: &[u8], out: &mut [u8], ms: u64) -> Result[u64, (NetErr, OsErr)] !{io, block} {
    net::send_all_timeout(c, msg, ms)?;
    match c.wait_readable(ms)? {
        true => match c.try_read(out)? {
            Some(n) => Ok(widen[u64](n)),
            None => Err((NetErr::WouldBlock, OsErr::Other)),
        },
        false => Err((NetErr::WouldBlock, OsErr::Other)),
    }
}

test bounded_request (mut sim: Sim) {
    let mut lst = sim.listen.bind("svc").unwrap();
    let mut client = sim.dial.connect("svc").unwrap();
    let mut server = lst.try_accept().unwrap().unwrap();
    let _s = net::send_all(&mut server, &b"pong").unwrap();
    let mut buf = [0u8; 8];
    match request(&mut client, &b"ping", &mut buf, 50u64) {
        Ok(n) => { assert n > 0u64; }
        Err(_) => { assert false; }
    }
    let _c1 = client.close();
    let _c2 = server.close();
    lst.close();
}

Любое ограниченное ожидание ограничивает одно ожидание, а не их последовательность. Цикл ограниченных чтений может длиться вечно, если собеседник отвечает точно в пределах каждого тайм-аута. Для настоящего дедлайна возьмите clock.now() перед циклом и сравнивайте с ним на каждой итерации.

Подключение с ограничением по времени

connect к адресу, который никто не слушает, сразу завершается ошибкой Refused: другая сторона явно ответила отказом. Хост, который выключен или закрыт файрволом, не отвечает вовсе, и обычный connect ждёт собственного тайм-аута операционной системы. connect_timeout(addr, ms) отвечает Result[Option[Conn], (NetErr, OsErr)] и даёт Ok(None), когда время вышло. В симуляторе незанятый адрес ведёт себя для этих двух вызовов по-разному, и тест может намеренно проверить случай «никто никогда не ответит»:

test connect_timeout_and_connect_differ (mut sim: Sim) {
    match sim.dial.connect_timeout("dead-peer", 5u64) {
        Ok(Some(c)) => { let _x = c.close(); assert false; }
        Ok(None) => {}
        Err((e, _)) => { assert e == NetErr::WouldBlock; }
    }
    match sim.dial.connect("dead-peer") {
        Ok(c) => { let _x = c.close(); assert false; }
        Err((e, _)) => { assert e == NetErr::Refused; }
    }
}

Ни один из вызовов не берёт случайность, так что результат одинаков при любом зерне.

Unix-сокеты

Unix-сокет связывает процессы на одной машине, и адресуется он путём в файловой системе, а не хостом и портом. Адрес с префиксом unix:, например "unix:/tmp/svc.sock", выбирает такой сокет в тех же вызовах connect и bind, без новой капабилити и без новых методов. Одно отличие существенно: закрытие слушающего Unix-сокета удаляет его запись в файловой системе, потому что ядро не освобождает путь сокета так, как освобождает порт. Удаление выполняется в рамках полномочия Listen, выданного при bind, а не через капабилити Fs.

use std::net;

test uds_roundtrip (mut sim: Sim) {
    region r {
        let msg = alloc in r [79u8, 75u8];  // "OK"
        let mut lst = sim.listen.bind("unix:/tmp/kola-wiki.sock").unwrap();
        let mut client = sim.dial.connect("unix:/tmp/kola-wiki.sock").unwrap();
        let mut server = lst.try_accept().unwrap().unwrap();
        let _s = net::send_all(&mut client, msg);
        let buf: &r mut Vec[u8] = alloc in r mut Vec::new();
        match net::recv_exact(&mut server, buf, 2usize, 10u64) {
            Ok(ok) => { assert ok; assert buf == msg; }
            Err(_) => { assert false; }
        }
        let _c1 = client.close();
        let _c2 = server.close();
        lst.close();
    }
}

Ожидание готовности

Ожидание готовности просит операционную систему усыпить программу, пока сокет нельзя будет прочитать или записать без блокировки или пока не истечёт тайм-аут. Это форма системного вызова poll в kola. Все ожидания готовности устроены одинаково. wait_readable(timeout_ms) и wait_writable(timeout_ms) отвечают Result[bool, (NetErr, OsErr)]: Ok(true) при готовности, Ok(false), когда ожидание истекло. Закрытый или сломавшийся сокет считается готовым, так что следующее чтение сообщит о конце потока или об ошибке. timeout_ms — это u64, который выжидается полностью, а зарезервированный литерал NO_DEADLINE означает ожидание без ограничения. c.peer_closed() спрашивает, ушёл ли собеседник, не читая и не дожидаясь.

c.wait_ready(timeout_ms) даёт оба ответа за одну пробу: Ready::Data, Ready::Closed или Ready::Timeout. На пути, который выполняется для каждого запроса, предпочитайте его: wait_readable с последующим peer_closed стоят две пробы.

use std::net;

test wait_ready_three_ways (mut sim: Sim) {
    let mut lst = sim.listen.bind("svc").unwrap();
    let mut client = sim.dial.connect("svc").unwrap();
    let mut server = lst.try_accept().unwrap().unwrap();
    assert server.wait_ready(5) == Ok(Ready::Timeout);
    let _w = net::send_all(&mut client, &b"hi");
    assert server.wait_ready(5) == Ok(Ready::Data);
    let mut buf = [0u8; 8];
    let mut got = 0usize;
    while got < 2 {
        match server.try_read(&mut buf) { Ok(Some(n)) => { got = got + n; } _ => { assert false; } }
    }
    let _c = client.close();
    assert server.wait_ready(5) == Ok(Ready::Closed);
    assert client_gone(&server);
    let _s = server.close();
    lst.close();
}

fn client_gone(c: &Conn) -> bool !{io} {
    c.peer_closed() == Ok(true)
}

UDP: те же две капабилити

Своей капабилити у датаграмм нет, потому что они отвечают на те же вопросы аудита. Вместо этого разделение проведено внутри протокола. listen.bind_udp(addr) занимает порт, до которого могут дойти непрошеные датаграммы, поэтому это полномочие Listen. dial.send_to(&sock, addr, bytes) — полномочие Dial, которое применяется при каждой отправке: сокет передаётся аргументом в метод Dial, так что отправляющий код не может существовать без &Dial в области видимости. Сокету, который и принимает, и отправляет, нужны обе строки бюджета. Чистый клиент, которому нужны только ответы на собственные запросы, пользуется dial.connect_udp(peer): тот занимает эфемерный порт, подключённый к этому собеседнику, так что ничего непрошеного прийти не может, и требует только Dial; отправляет такой клиент через dial.send(&sock, bytes).

use std::net;

test one_send_is_one_recv (mut sim: Sim) {
    let mut a = sim.listen.bind_udp("peer-a").unwrap();
    let mut b = sim.listen.bind_udp("peer-b").unwrap();

    let n = sim.dial.send_to(&a, "peer-b", &b"HI!").unwrap();
    assert n == 3usize;

    let mut buf = [0u8; 8];
    let (got, sender) = b.try_recv_from(&mut buf).unwrap().unwrap();
    assert got == 3usize;
    assert sender == "peer-a";

    let rn = sim.dial.send_to(&b, sender, &buf[0..3]).unwrap();
    assert rn == 3usize;

    a.close();
    b.close();
}

Несколько отличий от потоковой стороны сделаны намеренно. Датаграмма нулевой длины — это Ok(Some((0, addr))), а не конец потока, потому что у UDP его нет. try_recv_from возвращает адрес отправителя новой строкой, которая выделяется по тому же правилу, что и литерал f"..." (в регион места назначения, а если его нет — в самый внутренний открытый region); регион на каждый запрос не даёт долгоживущему серверу копить по адресу на каждую датаграмму. Широковещательная рассылка — отдельный метод send_broadcast, а не флаг сокета, так что широковещательный трафик находится поиском. join_multicast относится к Listen, потому что вступление в группу расширяет круг отправителей, способных достучаться до сокета.

Настройка и MTU пути

Здесь пригодятся два факта. Во-первых, ядро держит для каждого UDP-сокета очередь приёма; датаграммы, пришедшие, пока программа занята, ждут в ней, а когда она заполнена, новые отбрасываются. Во-вторых, у каждого сетевого пути есть максимальный размер передаваемого блока (MTU) — самый большой пакет, который он пропускает; в Ethernet это часто 1500 байт. IP-пакет большего размера либо разбивается на фрагменты, что медленно и ненадёжно, либо отвергается, и маршрутизаторы сообщают об отказе сообщением ICMP (ICMP — протокол, которым IP передаёт управляющие сообщения и сообщения об ошибках). Обнаружение MTU пути (path MTU discovery) — это поиск самого большого размера, который проходит. Для серверов и туннелей, которым важны эти детали, есть несколько операций UDP:

  • listen.set_recv_buffer(&sock, bytes) задаёт размер очереди ядра, где непрошеные датаграммы ждут, пока программа занята, и отвечает размером, который сообщило ядро. Linux сообщает удвоенный запрос (с учётом своих служебных данных) и ограничивает его значением net.core.rmem_max, если у процесса нет CAP_NET_ADMIN; macOS сообщает сам запрос. Сравните ответ с запрошенным, чтобы заметить ограничение. Это полномочие Listen, потому что вызов резервирует память ядра под входящий трафик.

  • dial.send_to_pmtu(&sock, addr, bytes, mode) и dial.send_pmtu(&sock, bytes, mode) — это send_to и send с режимом обнаружения MTU пути для каждой отправки: Pmtu::Dont позволяет датаграмме фрагментироваться, Pmtu::Do выставляет «не фрагментировать» и отвергает всё, что больше MTU маршрута, а Pmtu::Probe выставляет «не фрагментировать», но не обращает внимания на узнанный MTU. Слишком большая датаграмма даёт Err с NetErr::TooBig, и ничего не отправляется.

  • dial.send_to_pmtu_gso(&sock, addr, data, segment, mode) за один вызов отправляет серию датаграмм на один адрес: data нарезается каждые segment байтов (последний кусок может быть короче), не больше 64 датаграмм и 65 507 байтов. В Linux это один sendmsg с UDP_SEGMENT, так что нарезку выполняет сетевая карта с аппаратной сегментацией UDP; там, где ядро от этого отказывается, датаграммы уходят по одной. Ok(n) считает байты принятых датаграмм.

  • dial.route_mtu(&sock, addr) узнаёт MTU маршрута, ничего не отправляя, а sock.try_recv_error(&mut buf) извлекает из очереди одну ошибку ICMP или локальную ошибку в виде PathError. Когда очередь ошибок задействована, try_recv_from и отправки с MTU пути могут отвечать NetErr::Pending, сообщая, что там ждёт ошибка.

  • dial.connect_udp_iface(addr, ifname) — это connect_udp, привязанный к одному сетевому интерфейсу.

Операции с MTU пути есть только в Linux: на других системах и в симуляторе они отвечают NetErr::Invalid. Собственный бэкенд пока не реализует set_recv_buffer и send_to_pmtu_gso и отказывает в них по имени, так что программа, которая ими пользуется, собирается через C-бэкенд.

test udp_tuning_under_the_simulator (mut sim: Sim) {
    let mut a = sim.listen.bind_udp("peer-a").unwrap();
    // Симулятор отвечает запрошенным значением как размером очереди.
    assert sim.listen.set_recv_buffer(&a, 1048576) == Ok(1048576usize);
    // Операциям с MTU пути нужен настоящий сокет Linux.
    match sim.dial.send_to_pmtu_gso(&a, "peer-b", &b"abcdef", 3, Pmtu::Do) {
        Ok(_) => { assert false; }
        Err((e, _)) => { assert e == NetErr::Invalid; }
    }
    a.close();
}

Много сокетов, одно ожидание: Poller

Сервер с множеством соединений не может позволить себе по заблокированному потоку на соединение, а проверка соединений по очереди перестаёт работать уже после нескольких штук. Обычное решение — реактор: все сокеты регистрируются в одном объекте, один вызов ожидания возвращается, когда готов любой из них, и затем обрабатываются готовые (это то, что дают epoll в Linux и kqueue в macOS). Poller::new() создаёт линейный реактор готовности. У него по одному методу регистрации на каждый вид дескриптора: add для Conn, add_listener, add_udp, add_chan для приёмного конца канала, add_child для дочернего процесса, add_device для устройства TUN и add_signal для сигнала процесса. Каждый отвечает идентификатором слота. wait(timeout_ms) отвечает, сколько зарегистрированных дескрипторов готово (Ok(0) по истечении), next_ready() выдаёт их идентификаторы по одному, а remove(id) освобождает слот для повторного использования. Реактор только наблюдает за готовностью; зарегистрированными в нём дескрипторами он никогда не владеет, и жёсткого предела на их число у него нет.

test one_wait_serves_many_sockets (mut sim: Sim) {
    let mut lst = sim.listen.bind("svc").unwrap();
    let mut c1 = sim.dial.connect("svc").unwrap();
    let mut c2 = sim.dial.connect("svc").unwrap();
    let mut s1 = lst.try_accept().unwrap().unwrap();
    let mut s2 = lst.try_accept().unwrap().unwrap();

    let mut poller = Poller::new();
    let id1 = poller.add(&s1).unwrap();
    let id2 = poller.add(&s2).unwrap();

    // Ещё ничего не записано: ограниченное ожидание истекает, готовых нет.
    assert poller.wait(10u64).unwrap() == 0u64;

    let _w = c1.write(&b"x");
    assert poller.wait(10u64).unwrap() == 1u64;
    match poller.next_ready() {
        Some(id) => { assert id == id1; assert id != id2; }
        None => { assert false; }
    }
    match poller.next_ready() { Some(_) => { assert false; } None => {} }

    poller.close();
    let _a = s1.close();
    let _b = s2.close();
    let _d = c1.close();
    let _e = c2.close();
    lst.close();
}

test poller_slots_are_reused (mut sim: Sim) {
    let mut lst = sim.listen.bind("svc").unwrap();
    let mut u = sim.listen.bind_udp("dgram").unwrap();
    let mut poller = Poller::new();
    let id1 = poller.add_listener(&lst).unwrap();
    let id2 = poller.add_udp(&u).unwrap();
    let _r = poller.remove(id1);
    let id3 = poller.add_listener(&lst).unwrap();    // освобождённый слот используется снова
    assert id3 == id1 && id2 != id1;
    poller.close();
    lst.close();
    u.close();
}

poller.add_signal(&sys.proc, Signal::Term) превращает SIGTERM, SIGINT или SIGHUP в обычное событие готовности, без всяких обратных вызовов (в Linux через signalfd, в macOS через kqueue). В симуляторе sim.signal(Signal::Term) детерминированно доставляет зарегистрированный сигнал.

Один порт на несколько процессов

Супервизор, который один раз занимает порт и раздаёт его рабочим процессам, пользуется l.dup(), создающим второго владельца того же слушающего сокета, вместе с SpawnSlot::Listen при запуске (о запуске процессов — в главе Командная строка). listen.bind_reuseport(addr) устроен иначе: он делает порт общим для любого процесса, который тоже его запросит, и ядро распределяет между ними соединения, не сообщая об ошибке ни одной из сторон. Пользуйтесь им только для нескольких рабочих процессов одной службы. Обычный bind сообщает AddrInUse и уже сам справляется с перезапуском, пока старые соединения находятся в TIME_WAIT.

Сужение Dial для рабочего потока

Капабилити перемещается ровно один раз, так что с одним Dial у процесса может подключаться либо реактор, либо рабочий поток, но не оба. Клонирование Dial решило бы это, но ослабило бы гарантию. Вместо этого kola применяет ослабление. dial.to_host(host, port) возвращает новый Dial, которому доступна ровно эта конечная точка, а исходный остаётся у владельца. Ограничение проверяется во время выполнения в каждой операции, принимающей адрес (connect, connect_timeout, connect_udp, send_to, send_broadcast), и отвергнутый вызов отвечает Err с NetErr::Denied, а не трапом. Ограничение действует по имени, а не по адресу, так что попытка достучаться до того же хоста по IP-адресу тоже отвергается. Если сузить уже суженный Dial до другой конечной точки, получится Dial, которому недоступно ничего; сужение никогда не расширяет.

dial.split() покрывает случай, когда нужен второй дескриптор без сужения. send_to — операция Dial, так что поток, владеющий занятым UdpSock, не может отвечать через него, не владея и Dial. Отделённый Dial достаёт ровно до того же, что и его родитель.

Хост передаётся байтами, чтобы [caps] dial_hosts в манифесте мог перечислить литерал для kola audit. Этот бюджет касается только to_host: пакет, у которого есть полный Dial, по-прежнему может подключиться куда угодно.

test a_narrowed_dial_reaches_one_endpoint_only (mut sim: Sim) {
    let mut lst = sim.listen.bind("svc").unwrap();
    let mut other = sim.listen.bind("other").unwrap();
    let narrow = sim.dial.to_host(&b"svc", 0u16);
    // Исходному Dial по-прежнему доступно всё.
    let c = sim.dial.connect("other").unwrap();
    let _c = c.close();
    match narrow.connect("other") {
        Ok(c2) => { let _y = c2.close(); assert false; }
        Err((e, _)) => { assert e == NetErr::Denied; }
    }
    lst.close();
    other.close();
}

Детерминированное тестирование с SimNet

Под kola test sim.dial и sim.listen работают с сетью в памяти, управляемой зерном, — SimNet. Её сбой по зерну — короткое чтение: каждое чтение возвращает префикс доступных данных случайной длины и тем намеренно воспроизводит определяющее свойство TCP. Правильно написанная читающая функция, которая собирает данные через recv_exact, пока не получит сообщение целиком, проходит обмен в точности, как бы ни были нарезаны байты:

use std::net;

test reassembles_across_seeded_short_reads (mut sim: Sim) {
    region r {
        let msg: &r mut Vec[u8] = alloc in r mut Vec::new();
        for k in 0..40 {
            msg.push(narrow[u8](widen[u64](k)).unwrap());
        }
        let mut lst = sim.listen.bind("svc").unwrap();
        let mut client = sim.dial.connect("svc").unwrap();
        let mut server = lst.try_accept().unwrap().unwrap();
        let _s = net::send_all(&mut client, msg);
        let got: &r mut Vec[u8] = alloc in r mut Vec::new();
        match net::recv_exact(&mut server, got, 40usize, 50u64) {
            Ok(ok) => { assert ok; assert got == msg; }
            Err(_) => { assert false; }
        }
        let _c = client.close();
        let _s2 = server.close();
        lst.close();
    }
}

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

use std::net;

test naive_reader_can_tear (mut sim: Sim) {
    region r {
        let msg = alloc in r [107u8, 111u8, 108u8, 97u8];
        let mut lst = sim.listen.bind("svc").unwrap();
        let mut client = sim.dial.connect("svc").unwrap();
        let mut server = lst.try_accept().unwrap().unwrap();
        match net::send_all(&mut client, msg) { Ok(_) => {} Err(_) => { assert false; } }
        let mut buf = [0u8; 4];
        match server.wait_readable(10u64) {
            Ok(true) => {
                match server.try_read(&mut buf) {
                    Ok(Some(n)) => { assert n == 4usize; }
                    Ok(None) => { assert false; }
                    Err(_) => { assert false; }
                }
            }
            Ok(false) => { assert false; }
            Err(_) => { assert false; }
        }
        let _c = client.close();
        let _s = server.close();
        lst.close();
    }
}
$ kola test --interp --seed 7 naive.kl
naive.kl: test naive_reader_can_tear ... FAILED (seed 7)
error[card §8]: assertion failed
  --> naive.kl:14:38
    |
 14 |                     Ok(Some(n)) => { assert n == 4usize; }
    |                                      ^^^^^^

0 passed, 1 failed (interpreted, JIT backend)

У 4-байтового сообщения мало шансов остаться целым при коротком чтении, поэтому тест падает при большинстве зёрен. kola test --seed N в точности воспроизводит конкретный сбой. Тест, проходящий при многих зёрнах, показывает, что протокол правильно обращается с потоком байтов, а не что ему однажды повезло.

Настоящие сокеты

Тот же код работает и на настоящих сокетах. Dial и Listen (TCP, UDP, Unix-сокеты и Poller) реализованы и в интерпретаторе, и в C-бэкенде, так что kola run, kola run --native и kola build пользуются сокетами операционной системы, а kola test для теста с (mut sim: Sim) — SimNet. Тест, объявленный с (mut sys: Sys), выполняется вживую, на настоящих сокетах в обоих исполнителях.

У симуляции есть пределы. SimNet детерминированно моделирует фрагментацию и состояние соединений; буферы отправки ядра, реальные времена готовности и всё поведение враждебного собеседника он не моделирует. Если утверждение от них зависит, добавьте живой тест. И помните о разнице с try_accept, упомянутой выше: в симуляторе запущенный поток не выполняется, пока родитель не уступит ему управление, так что первый try_accept серверного потока всегда застаёт клиента ожидающим, а в настоящей системе может ответить Ok(None). Перебор зёрен этого не найдёт; найдёт только нативный запуск.

Нижние уровни

Некоторым программам сокетов мало. Каждый из перечисленных ниже механизмов — отдельная капабилити со своим разделом в STD-CARD, и ни один не доступен через Dial или Listen:

  • Пакетные сокеты на собственном устройстве TUN (Linux). Устройство TUN — виртуальный сетевой интерфейс, пакеты которого уходят программе, а не в кабель; на них строятся VPN. С выданным устройством TUN (Device) вызовы listen.bind_packet, bind_udp_packet и bind_transparent_packet привязывают сокеты к интерфейсу этого устройства. PacketUdp получает датаграмму вместе с PacketReply, через который можно ответить только запомненному собеседнику с запомненного адреса отправителя и интерфейса.

  • Сырой IP (sys.raw_ip, Linux). raw.socket() открывает линейный RawIpSock, а send принимает один полный пакет IPv4 или IPv6, заголовок которого определяет семейство и адрес назначения. От Dial он отделён потому, что тот, кто пишет заголовок, может указать любой адрес отправителя.

  • Netlink (sys.netlink, Linux). netlink.socket(family) открывает NetlinkSock, ограниченный одним выданным семейством (link, neigh, addr, route и другими), для работы с таблицами маршрутизации и соседей ядра. Netlink — интерфейс Linux, через который сеть настраивают программы вроде ip. Под kola test семейство работает с симулированной моделью и до ядра хоста не доходит.