Сеть: 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семейство работает с симулированной моделью и до ядра хоста не доходит.