Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
Проект: веб-сервер TINY
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Проект: веб-сервер TINY
В прошлом уроке TINY был чёрным ящиком: мы слали ему байты через
ncиcurlи разбирали, что он отвечает, а заодно написали его чистую часть, разбор строки запроса, заголовков и URI. Сегодня откроем ящик. Циклaccept, одна функция на транзакцию, файл черезmmapоднимwrite, программа черезfork,dup2иexecve, страница ошибки на каждый отказ: около трёхсот строк Zig, и в каждой строке встречается что-то из прошлых блоков. Потом научим сервер тому, чего не умеет книжный TINY: отвечать наHEAD, принимать телоPOST, отдавать видео и переживать клиента, который бросил соединение посреди ответа. А в концеzboxполучит HTTP-фасад: тот же сервер, подключённый библиотекой, примет исходник поPOST /run, соберёт его, запустит под лимитами и ответит JSON-ом.
Цели урока
- Собрать TINY целиком и уметь пройти путь одного запроса от
acceptдоclose, называя системный вызов на каждом шаге. - Объяснить, какой отказ превращается в какой код ответа (400, 403, 404, 413, 414, 431, 501) и почему страница ошибки обязана дойти до клиента.
- Отдавать статику через
mmapиwritenбез копии в буфере программы, и знать, почемуHEADи пустой файл идут мимоmmap. - Запускать CGI: проверка файла до статус-строки, окружение до
fork, телоPOSTчерез пайп,waitpidи код выхода в логе. - Понимать, чем
SIG_IGNотличается от обработчикаSIGPIPEпослеexecve, и почему это важно и серверу, и песочнице. - Подключить один пакет Zig к другому по пути (
build.zig.zon) и построить на библиотекеtinyсервисzbox serveс маршрутомPOST /run. - Тестировать сервер в том же процессе: сервер в потоке, клиент на сокете, и не попасться на указатель в копию структуры.
Идея: сервер отвечает на четыре вопроса
У любого веб-сервера на каждое соединение одни и те же четыре вопроса. Кто пришёл: accept возвращает новый дескриптор и адрес клиента. Что просят: строка запроса, заголовки, тело. Что это на диске: parseUri решает, файл это или программа. Кто ответит: сервер сам (статика) или программа, которой сервер отдаст сокет (CGI). Потом соединение закрывается, и цикл идёт за следующим клиентом.
Книжный TINY отвечает на эти вопросы функциями с говорящими именами, и наш повторяет их почти один в один. Вот карта соответствий, по ней удобно читать весь урок:
| Книга, C | У нас, Zig | Где живёт |
|---|---|---|
main: Open_listenfd, цикл Accept, Close | Server.init, serveForever, serveOnce | src/http/tiny.zig |
doit | Server.doit | src/http/tiny.zig |
Rio_readlineb | fdio.Reader и takeDelimiterInclusive | src/net/fdio.zig, урок 64 |
read_requesthdrs | request.readRequestHeaders | src/http/request.zig, урок 65 |
parse_uri | request.parseUri | там же |
serve_static, get_filetype | Server.serveStatic, request.contentType | tiny.zig, request.zig |
serve_dynamic | Server.serveDynamic | src/http/tiny.zig |
clienterror | clienterror | src/http/tiny.zig |
Rio_writen | fdio.writen и fdio.Writer | src/net/fdio.zig |
Книжная версия умещается примерно в двести пятьдесят строк C, наша в триста сорок строк Zig плюс двести строк разбора из прошлого урока. Разница в том, что книга молча пропускает. sscanf не проверяет, что слов в строке запроса три. parse_uri не видит .. в пути. Заголовки читаются и выбрасываются, поэтому тело POST принять нельзя. Первая же запись в сокет, который клиент уже закрыл, убивает сервер. Мы закроем каждую из этих дыр и для каждой покажем живой прогон.
Виджет: один запрос через TINY
Схема ниже нарисована по книжному TINY, поэтому имена в ней сишные: rio_readlineb, sscanf, serve_static. Нажми на узел: под схемой появится, что он делает, какие системные вызовы стоят за ним и где у него слабое место. Две вещи из этих подписей мы сегодня исправим руками: разбор строки запроса без проверки и смерть от SIGPIPE в serve_static и clienterror.
accept
Цикл main: слушающий сокет ждёт, accept отдаёт новый connfd для одного клиента. getnameinfo печатает, кто пришёл.
accept блокируется, пока в очереди listenfd пусто. EINTR после сигнала: обёртка Accept просто повторяет вызов.
doit
Одна транзакция целиком. Читает строку запроса буферизованным rio_readlineb, разбирает её sscanf на метод, URI и версию.
Метод не GET: clienterror 501 и возврат. Клиент прислал мусор без пробелов: sscanf вернёт меньше трёх полей, TINY книги это не проверяет.
read_requesthdrs
Читает заголовки по строке до пустой строки и выбрасывает их: TINY не использует ни один.
Строка длиннее MAXLINE обрезается. Клиент закрыл соединение раньше пустой строки: readlineb вернёт 0, цикл кончится на EOF.
parse_uri
Чистая функция. Без «cgi-bin» в URI это статика: filename «.» плюс URI, а «/» в конце дополняется home.html. Иначе аргументы после «?» уходят в cgiargs.
Никакой защиты от «..» в пути: /../etc/passwd пройдёт до stat. Домашнее задание книги про это молчит, а в проекте это первый патч.
serve_static
stat уже сделан в doit: тип по расширению, заголовки одной строкой в сокет, файл отображается в память и пишется целиком.
Клиент ушёл посреди ответа: write получает EPIPE и процесс ловит SIGPIPE, без обработчика сервер умирает. Большой файл целиком в mmap, это дорого, но короткие счёты write закрывает rio_writen.
serve_dynamic
Первые две строки ответа пишет сам, дальше fork: ребёнок кладёт cgiargs в QUERY_STRING, перенаправляет stdout в сокет и запускает программу. Родитель ждёт.
CGI-программа обязана сама напечатать Content-type и пустую строку. Забыла waitpid: зомби на каждый запрос. stdio-буфер ребёнка не сброшен до execve: кусок ответа уедет дважды.
clienterror
Собирает HTML-страницу ошибки и заголовки к ней, пишет всё в сокет и возвращается в doit. Коды: 501 метод, 404 нет файла, 403 нельзя читать или запускать.
Сам может упасть на EPIPE так же, как serve_static. Content-length считается по телу, иначе клиент ждал бы до закрытия.
close
main закрывает connfd: сервер шлёт FIN, клиент видит EOF и знает, что ответ кончился. listenfd остаётся, цикл идёт к следующему accept.
Забытый close: дескрипторы кончаются через несколько сотен запросов, accept отвечает EMFILE. В serve_dynamic ребёнок тоже держит connfd, execve его наследует.
Итеративный сервер: accept в цикле
Первый вопрос, кто пришёл, решается так же, как в эхо-сервере из урока про сокеты. Server.init открывает слушающий сокет через openListenfd, serveOnce принимает одно соединение, обслуживает и закрывает, serveForever повторяет это бесконечно.
Сервер итеративный. Пока идёт одна транзакция, остальные клиенты ждут: ядро уже приняло их соединения (трёхстороннее рукопожатие проходит без участия программы) и держит в очереди listen, длиной до 1024 в нашем openListenfd. Для урока это лучший выбор: никаких гонок, логи идут строго по порядку, а медленный клиент наглядно держит всех. Как с этим бороться процессами, select и потоками, разберёт следующий блок.
Каждая транзакция получает свою арену. Всё, что выделено по ходу разбора запроса (тело, имя файла с корнем, окружение CGI), освобождается одним deinit в конце serveOnce, а не по штуке. Ошибка внутри транзакции не выходит за serveOnce: она становится строкой лога, и сервер идёт к следующему accept. Один плохой клиент не должен ронять всех остальных.
Закрытие соединения здесь не формальность. TINY говорит на HTTP/1.0 с Connection: close, и для клиента close на стороне сервера означает конец ответа: read у клиента возвращает ноль. Забудь close, и клиент будет ждать вечно, а сервер через тысячу запросов упрётся в лимит дескрипторов и получит EMFILE на accept. Поэтому defer socket.close(conn.fd) стоит сразу после accept.
doit: от байтов к решению
doit это одна транзакция. Порядок действий в ней важнее кода, поэтому сначала словами.
- Строка запроса через
takeDelimiterInclusive('\n'). Клиент, который подключился и молча ушёл, не ошибка:EndOfStreamзначит просто вернуться. parseRequestLineиз урока 65: три слова, версия начинается сHTTP/, URI со слеша.- Заголовки через
readRequestHeadersдо пустой строки. Нам нужны два:Content-LengthиHost. - Тело ровно по
Content-Length, не большеmax_body.chunkedэтот сервер не понимает. - Свои маршруты (их вешает
zbox), потом статика или CGI. Если статика выключена (serve_files = false), всё, что не маршрут, получает 404. - Метод.
GET,HEADиPOSTсервер знает, остальное 501. parseUri, и дальшеserveStaticилиserveDynamic.
Каждый отказ по дороге превращается в свой код, и клиент получает страницу clienterror с этим кодом и причиной:
| Что пошло не так | Код | Где заметили |
|---|---|---|
строка запроса не из трёх слов, не HTTP/, длиннее буфера | 400 | parseRequestLine, читатель |
заголовок без двоеточия, Content-Length не число | 400 | readRequestHeaders |
тело короче обещанного Content-Length | 400 | readSliceAll |
| заголовков больше 8 КБ или больше 64 штук | 431 | Headers.append |
тело больше max_body | 413 | до чтения тела |
метод не GET, не HEAD и не POST | 501 | перед parseUri |
сегмент .. в пути | 403 | parseUri |
URI не влез в буферы parseUri | 414 | parseUri |
| файла нет | 404 | openFile, statFile |
| не обычный файл, нет прав на чтение | 403 | serveStatic |
| CGI не обычный файл или без бита исполнения | 403 | serveDynamic |
| обработчик маршрута вернул ошибку | 500 | цикл маршрутов |
У ответа есть одна тонкость. doit пишет в сокет через буферизованный fdio.Writer с буфером на 8 КБ, и страница ошибки на ранний отказ легко помещается в буфер целиком. Если после clienterror просто выйти из функции, буфер пропадёт вместе со стековой переменной, и клиент на свою 400 получит пустое закрытое соединение. Поэтому первой строкой после создания писателя стоит defer out.flush() catch {};: каким бы путём мы ни вышли из doit, ответ уйдёт в сокет. catch {} здесь честный: если клиент уже ушёл, сообщить об этом некому.
Второе, на что стоит посмотреть, это где сервер пишет лог. Строка access пишется ровно там, где решён код ответа, одна строка на запрос в stderr: клиент, строка запроса, код, байты тела. Для CGI байтов ноль, тело пишет ребёнок, и сервер его не видит. Зато для CGI строка пишется после waitpid, и в её конце видно, чем кончилась программа: cgi exit 0, cgi exit 127, cgi signal 9.
Статика: mmap и один writen
Статический ответ это заголовок и файл. Как отдать файл, не копируя его? В книге ответ такой: отобразить файл в память через mmap из урока про отображение памяти и отдать отображённую область в write целиком. Байты файла попадают в страничный кэш ядра один раз, процесс видит те же страницы через свою таблицу страниц, и write копирует их из кэша прямо в буфер сокета. Ни одной копии в памяти программы, ни одного read.
serveStatic делает это в пять шагов:
openFile. Нет файла это 404, нет прав или что-то ещё это 403.stat. Не обычный файл (каталог, устройство) это 403. Размер нужен дляContent-length.- Заголовок в буфер писателя и
flush: он должен уйти в сокет раньше тела. mmapна весь размер, только чтение, приватное отображение.out.writeAll(mapped). Срез больше буфера писателя, поэтомуstd.Io.Writerне копирует его в буфер, а сразу отдаёт вdrain, аdrainуfdio.Writerэтоwritenиз урока про короткие счёты. Сокет вправе принять за одинwriteменьше, чем просили, иwritenдописывает остаток циклом.
Две ветки идут мимо mmap. HEAD это GET без тела: заголовки те же, с честным Content-length, а файл даже не отображается. И пустой файл: mmap нулевой длины это EINVAL и на Linux, и на macOS, поэтому при size == 0 сервер возвращается сразу после заголовка.
munmap стоит в defer: отображение живёт ровно до конца записи. Если клиент бросит соединение посреди 64 МБ, writen вернёт ошибку, defer снимет отображение, file.close закроет файл, и сервер пойдёт дальше. Что при этом происходит с сигналами, разберём ниже, это отдельная история.
Динамика: CGI целиком
Механику CGI мы собрали в прошлом уроке на cgirun.zig: переменные окружения, fork, dup2(connfd, 1), execve, буфер, который обязан уйти в сокет до fork. serveDynamic делает то же самое, и нового в ней четыре вещи.
Проверка до статус-строки. Сервер пишет HTTP/1.0 200 OK до fork, и после этого передумать уже нельзя: код ответа ушёл. Поэтому всё, что можно проверить заранее, проверяется заранее. Нет файла это 404. Файл не обычный или у владельца нет бита исполнения (S_IXUSR, как в книге) это 403 с понятной страницей. Проверка не всесильна: файл с битом исполнения, внутри которого текст, пройдёт её, а execve откажет с ENOEXEC. Ребёнок тогда выходит через _exit(127), клиент получает ответ, оборванный после двух строк, а в логе появляется cgi exit 127. Это цена, которую TINY платит за простоту. Apache, например, читает вывод программы через пайп и может ответить 500, если программа упала до первого байта.
Окружение до fork. argv и envp для execve собираются в арене заранее. После fork в ребёнке многопоточного процесса можно звать только async-signal-safe функции, а аллокатор к ним не относится. Мы разбирали это в уроке про процессы, и тот же приём стоит в run.zig у zbox. Окружение ребёнка содержит ровно три переменные: QUERY_STRING, REQUEST_METHOD, CONTENT_LENGTH. Ни PATH, ни HOME сервера программа не видит.
Тело POST через пайп. По стандарту CGI тело запроса программа читает из стандартного ввода, ровно CONTENT_LENGTH байт. Сервер уже прочитал тело из сокета в doit, поэтому отдать ребёнку сокет как stdin нельзя: байты тела лежат в буфере читателя сервера, а не в сокете. Решение: пайп. Ребёнок делает dup2(body_pipe[0], 0), родитель после fork пишет тело в body_pipe[1] и закрывает его. Закрыть обязательно: программа, которая читает stdin до конца, иначе не дождётся EOF, как wc в конвейере из урока про пайпы. Если программа прочитала меньше, чем ей дали, и вышла, запись родителя получит EPIPE, и catch {} его проглотит: программа сказала своё, остальное тело никому не нужно.
waitpid и строка лога. Родитель ждёт ребёнка в цикле: EINTR значит, что ожидание прервал сигнал, и ждать надо дальше. Любая другая ошибка значит, что ребёнка нет, и строка лога получает cgi unknown. Без waitpid каждый CGI-запрос оставлял бы зомби: итеративный сервер живёт долго, и таблица процессов закончилась бы.
Посмотри ещё раз на ребёнка. Он делает dup2 и close и сразу execve. Никаких правок диспозиций сигналов, и это не случайность, а тема следующего раздела.
SIGPIPE: клиент бросил трубку
Возьмём файл на 64 МБ и попросим его так, как просит нетерпеливый клиент: прочитаем шестнадцать байт и закроем соединение.
$ head -c 67108864 /dev/urandom > big.bin
$ curl -s http://localhost:8066/big.bin | head -c 16 | xxd
00000000: fa60 9026 790a 2aec 22b0 bf42 c79d 343a .`.&y.*."..B..4:
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8066/hello.txt
200
В логе сервера:
127.0.0.1:62958 "GET /big.bin HTTP/1.1" 200 67108864
127.0.0.1:62958 transaction failed: WriteFailed
127.0.0.1:62970 "GET /hello.txt HTTP/1.1" 200 16
head прочитал свои байты и вышел, curl получил SIGPIPE в своей записи и вышел, соединение закрылось. Сервер в этот момент был внутри writen, и очередной write в сокет, у которого больше нет читателя, получил ответ ядра по тому же правилу, что пайп в уроке про пайпы: процессу посылается SIGPIPE, а если сигнал не убил процесс, write возвращает -1 с errno = EPIPE. Наш сервер не умер, потому что Server.init первым делом ставит на SIGPIPE SIG_IGN. Ошибка дошла до serveOnce и стала строкой лога, следующий клиент получил свои 200.
Книжный TINY здесь падает: действие по умолчанию для SIGPIPE это завершение процесса, и один нетерпеливый клиент гасит сервер для всех. Именно это просит починить домашнее 11.13.
Обработчик или SIG_IGN
В уроке про пайпы мы выяснили, что std.Io.Threaded.init в Zig 0.16 уже ставит на SIGPIPE пустой обработчик, так что программа с pub fn main(init: std.process.Init) живёт по серверным правилам с первой строки. Зачем тогда TINY своё SIG_IGN? И есть ли разница между «ничего не делающим обработчиком» и «игнорировать»?
Для самого процесса разницы нет: в обоих случаях сигнал его не убивает, и write возвращает EPIPE. Разница появляется на execve. Из урока про сигналы ты помнишь правило: при execve каждый установленный обработчик сбрасывается в действие по умолчанию (адрес функции в новом образе ничего не значит), а игнорируемый сигнал остаётся игнорируемым. Проверим. Каждый ребёнок ставит себе одну из трёх диспозиций и запускает через sh конвейер, где yes пишет в пайп, а head -1 читает одну строку и уходит:
//! Три диспозиции SIGPIPE и что от них остаётся после execve. Каждый
//! ребёнок ставит себе диспозицию и запускает через sh конвейер, в котором
//! `yes` пишет в пайп, а `head -1` читает строку и выходит.
const std = @import("std");
const c = std.c;
const posix = std.posix;
const Mode = enum { default, ignore, handler };
fn nothing(_: posix.SIG) callconv(.c) void {}
fn spawn(mode: Mode) void {
const pid = c.fork();
if (pid == 0) {
const action: posix.Sigaction = .{
.handler = switch (mode) {
.default => .{ .handler = posix.SIG.DFL },
.ignore => .{ .handler = posix.SIG.IGN },
.handler => .{ .handler = nothing },
},
.mask = posix.sigemptyset(),
.flags = 0,
};
posix.sigaction(.PIPE, &action, null);
const script = "(yes; echo \" yes вышел с кодом $?\" >&2) | head -1 >/dev/null";
const argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", script, null };
_ = c.execve("/bin/sh", &argv, c.environ);
c._exit(127);
}
var status: c_int = 0;
_ = c.waitpid(pid, &status, 0);
}
pub fn main() void {
inline for (.{ Mode.default, Mode.ignore, Mode.handler }) |mode| {
std.debug.print("{t}:\n", .{mode});
spawn(mode);
}
}
$ zig run sigpipe_exec.zig -lc
default:
yes вышел с кодом 141
ignore:
yes: stdout: Broken pipe
yes вышел с кодом 1
handler:
yes вышел с кодом 141
С действием по умолчанию yes убит сигналом 13, и оболочка честно пишет 141, это 128 + 13. С обработчиком то же самое: execve сбросил обработчик, и yes получил обычный смертельный SIGPIPE. А игнорирование пережило и execve ребёнка, и fork оболочки, и ещё один execve в yes: yes получил EPIPE, напечатал ошибку и вышел с кодом 1. Под Linux выводы те же, только yes из GNU coreutils пишет standard output вместо stdout.
Теперь ответ на вопрос «зачем своё SIG_IGN». tiny это библиотека. Её Server может оказаться в программе с голым pub fn main() void, в тесте с другой реализацией Io или в чужом коде, который поставил на SIGPIPE свой обработчик и потом снял. Сервер, чья живучесть зависит от того, как его запустили, ненадёжен, поэтому Server.init сам говорит ядру «игнорировать». Цена этого решения тоже видна: игнорирование наследуют все дети сервера через execve. CGI-программа под TINY стартует с игнорируемым SIGPIPE и вместо тихой смерти получает EPIPE. Для adder это не важно, он пишет один раз. А вот zbox serve, который запускает чужие программы, обязан вернуть им нормальный сигнал, и мы сделаем это в шаге проекта.
Есть и третий путь, без глобальной диспозиции вовсе. В Linux у send есть флаг MSG_NOSIGNAL: одна запись в сокет не посылает сигнал, а сразу возвращает EPIPE. В macOS и BSD вместо него опция сокета SO_NOSIGPIPE. Это аккуратнее, чем трогать сигналы всего процесса, но непереносимо, и через write не работает. Серверы вроде nginx просто игнорируют SIGPIPE при старте, как наш.
Ещё одна деталь лога. Строка говорит WriteFailed, а не BrokenPipe, хотя writen вернул именно BrokenPipe. Интерфейс std.Io.Writer умеет сообщать только одну ошибку записи, error.WriteFailed, и настоящая причина оседает в поле err нашего fdio.Writer. В упражнениях ты заставишь лог её показать.
HEAD, POST и видео
Остальные расширения из домашних заданий книги умещаются в несколько строк каждое, и все решения уже встречались выше.
HEAD (домашнее 11.11). Для статики serveStatic пишет те же заголовки с честным Content-length и возвращается, файл не отображается. CGI получает REQUEST_METHOD=HEAD и тело не печатает сама: сервер её вывод не видит и отрезать тело не может. Если программа про HEAD не знает, клиент получит тело, которого не просил. curl -I в этом случае просто закроет соединение после заголовков.
POST (домашнее 11.12). Сервер читает тело по Content-Length в doit, CGI получает его через пайп. Аргументы после ? по-прежнему лежат в QUERY_STRING, так велит стандарт CGI, а какой источник главнее, решает программа. Наш adder при POST берёт числа из тела, ровно CONTENT_LENGTH байт из stdin, и хвост после ? не смотрит. Почему ровно столько, а не до EOF? Потому что EOF в stdin CGI бывает не всегда: у серверов, которые отдают программе сокет напрямую, EOF придёт только когда клиент закроет соединение, а он ждёт ответа. Читать ровно CONTENT_LENGTH это единственный способ, который работает везде.
Видео (домашнее 11.7). Файл .mp4 отдаётся с типом video/mp4, и браузер покажет его своим плеером. Заголовок Range TINY не понимает, поэтому перемотка в браузере работает плохо: плеер просит кусок с середины, а получает файл целиком с начала. Ответ 206 Partial Content это домашнее задание урока.
$ head -c 1048576 /dev/zero > clip.mp4
$ curl -I http://localhost:8066/clip.mp4
HTTP/1.0 200 OK
Server: Tiny Web Server
Connection: close
Content-length: 1048576
Content-type: video/mp4
Шаг проекта: TINY целиком
Шаг добавляет в эталон src/http/tiny.zig, подкоманду tiny в src/main.zig, публичное API в src/root.zig, тесты и POST в adder. Разбор запроса, request.zig, остался ровно таким, каким мы его написали в прошлом уроке, вместе с кодом 414 в statusText. Сокеты и дескриптор как Reader и Writer пришли из урока про сокеты: socket.openListenfd, socket.accept (возвращает дескриптор и имя клиента host:port), socket.localPort, socket.close, fdio.Reader, fdio.Writer, fdio.writen, fdio.readn.
tiny.zig
Весь сервер одним файлом. Сверху типы, которые видит внешний код: Request, Response, Route, Options. Их мы разберём в шаге zbox, сейчас достаточно знать, что routes проверяются первыми, а serve_files = false оставляет от сервера только маршруты.
//! TINY: итеративный веб-сервер из главы 11. `doit` обслуживает одно
//! соединение: строка запроса, заголовки, тело по `Content-Length`,
//! потом либо свой маршрут (`routes`, для zbox), либо статика через `mmap`
//! и `write`, либо CGI через `fork`, `dup2` в сокет и `execve`.
//! Расширения из домашних 11.6 до 11.13: `HEAD`, `POST`, `SIGPIPE`, видео.
const std = @import("std");
const c = std.c;
const posix = std.posix;
const Io = std.Io;
const fdio = @import("../net/fdio.zig");
const socket = @import("../net/socket.zig");
const request = @import("request.zig");
pub const Method = request.Method;
pub const Headers = request.Headers;
/// Разобранный запрос, приходит в обработчик маршрута.
pub const Request = struct {
method: Method,
method_text: []const u8,
/// URI как прислали, вместе с `?args`.
uri: []const u8,
/// URI до `?`.
path: []const u8,
/// Всё после `?`, пустая строка без него.
query: []const u8,
version: []const u8,
headers: *const Headers,
/// Тело по `Content-Length`, не больше `Options.max_body`.
body: []const u8,
/// `host:port` клиента.
peer: []const u8,
};
pub const Response = struct {
status: u16 = 200,
content_type: []const u8 = "text/plain; charset=utf-8",
body: []const u8 = "",
};
/// Обработчик маршрута. Тело ответа выделяется в `arena`, она живёт
/// до конца транзакции. Ошибка обработчика превращается в ответ 500.
pub const Handler = *const fn (arena: std.mem.Allocator, req: *const Request) anyerror!Response;
pub const Route = struct {
method: Method,
path: []const u8,
handle: Handler,
};
pub const Options = struct {
/// 0 значит любой свободный порт, реальный лежит в `Server.port`.
port: u16,
/// Корень статики и `cgi-bin`, как `./` в книге.
root: []const u8 = ".",
/// Свои маршруты, проверяются до статики и CGI.
routes: []const Route = &.{},
/// Тело больше этого получает 413.
max_body: usize = 1 << 20,
/// Не писать строку на запрос в stderr.
quiet: bool = false,
/// Отдавать статику и CGI из `root`. Без них сервер знает только
/// `routes`, на всё остальное 404: так zbox не раздаёт свой каталог.
serve_files: bool = true,
};
pub const Error = socket.Error || error{OutOfMemory};
pub const Server = struct {
gpa: std.mem.Allocator,
io: Io,
listenfd: c.fd_t,
/// Порт, на котором сервер слушает на самом деле.
port: u16,
options: Options,
/// Открывает слушающий сокет и выключает `SIGPIPE`: клиент, закрывший
/// соединение посреди ответа, должен стоить серверу одной ошибки
/// `EPIPE` от `write`, а не смерти процесса.
pub fn init(gpa: std.mem.Allocator, io: Io, options: Options) Error!Server {
ignoreSigpipe();
const listenfd = try socket.openListenfd(options.port);
return .{
.gpa = gpa,
.io = io,
.listenfd = listenfd,
.port = socket.localPort(listenfd) orelse options.port,
.options = options,
};
}
pub fn deinit(s: *Server) void {
socket.close(s.listenfd);
s.* = undefined;
}
/// Принять одно соединение, обслужить, закрыть. Ошибки транзакции
/// уходят в лог: один плохой клиент не должен ронять сервер.
pub fn serveOnce(s: *Server) Error!void {
var name_buf: [socket.name_max_len]u8 = undefined;
const conn = try socket.accept(s.listenfd, &name_buf);
defer socket.close(conn.fd);
var arena_state: std.heap.ArenaAllocator = .init(s.gpa);
defer arena_state.deinit();
s.doit(arena_state.allocator(), conn.fd, conn.name) catch |err| s.log("{s} transaction failed: {t}", .{ conn.name, err });
}
pub fn serveForever(s: *Server) Error!void {
while (true) try s.serveOnce();
}
/// Одна транзакция HTTP.
fn doit(s: *Server, arena: std.mem.Allocator, connfd: c.fd_t, peer: []const u8) !void {
var in_buf: [8192]u8 = undefined;
var out_buf: [8192]u8 = undefined;
var reader: fdio.Reader = .init(connfd, &in_buf);
var writer: fdio.Writer = .init(connfd, &out_buf);
const out = &writer.interface;
// Ответ уходит из буфера при выходе, каким бы путём мы ни вышли:
// страница clienterror на ранний 400 или 413 тоже должна дойти.
defer out.flush() catch {};
// Строка запроса. Клиент, закрывший соединение молча, не ошибка.
const raw_line = reader.interface.takeDelimiterInclusive('\n') catch |err| switch (err) {
error.EndOfStream => return,
error.StreamTooLong => return clienterror(out, 400, "request line too long"),
error.ReadFailed => return error.ReadFailed,
};
const line = request.parseRequestLine(raw_line) catch return clienterror(out, 400, "malformed request line");
var headers: Headers = .{};
request.readRequestHeaders(&reader.interface, &headers) catch |err| return switch (err) {
error.HeaderTooLong => clienterror(out, 431, "request headers too large"),
error.ReadFailed => error.ReadFailed,
else => clienterror(out, 400, "malformed headers"),
};
// Тело: только по Content-Length, chunked этот сервер не знает.
const content_length = headers.content_length orelse 0;
if (content_length > s.options.max_body) return clienterror(out, 413, "request body too large");
const body = try arena.alloc(u8, content_length);
reader.interface.readSliceAll(body) catch |err| return switch (err) {
error.EndOfStream => clienterror(out, 400, "body shorter than Content-Length"),
error.ReadFailed => error.ReadFailed,
};
const question = std.mem.indexOfScalar(u8, line.uri, '?');
const req: Request = .{
.method = line.method,
.method_text = line.method_text,
.uri = line.uri,
.path = if (question) |q| line.uri[0..q] else line.uri,
.query = if (question) |q| line.uri[q + 1 ..] else "",
.version = line.version,
.headers = &headers,
.body = body,
.peer = peer,
};
// Свои маршруты идут первыми: zbox вешает сюда POST /run.
for (s.options.routes) |route| {
if (route.method == req.method and std.mem.eql(u8, route.path, req.path)) {
const response = route.handle(arena, &req) catch |err| {
s.log("{s} {s} {s}: handler failed: {t}", .{ peer, req.method_text, req.uri, err });
return clienterror(out, 500, "handler failed");
};
try request.writeResponseHead(out, response.status, response.content_type, response.body.len);
try out.writeAll(response.body);
s.access(&req, response.status, response.body.len);
return;
}
}
if (!s.options.serve_files) {
s.access(&req, 404, 0);
return clienterror(out, 404, "no such route");
}
if (req.method == .other) {
s.access(&req, 501, 0);
return clienterror(out, 501, "Tiny does not implement this method");
}
var filename_buf: [std.fs.max_path_bytes]u8 = undefined;
var cgiargs_buf: [4096]u8 = undefined;
const parsed = request.parseUri(req.uri, &filename_buf, &cgiargs_buf) catch |err| {
const status: u16 = if (err == error.Forbidden) 403 else 414;
s.access(&req, status, 0);
return clienterror(out, status, if (status == 403) "path escapes the root" else "uri too long");
};
// parseUri даёт `./x`, корень сервера подставляем вместо точки.
const path = try std.fmt.allocPrint(arena, "{s}{s}", .{ s.options.root, parsed.filename[1..] });
if (parsed.is_static) {
return s.serveStatic(out, &req, path);
}
return s.serveDynamic(arena, out, connfd, &req, path, parsed.cgiargs);
}
/// Статика: файл целиком отображается в память через `mmap` и уходит
/// в сокет одним `writen`. Ни одной копии в буфере программы.
fn serveStatic(s: *Server, out: *Io.Writer, req: *const Request, path: []const u8) !void {
const file = Io.Dir.cwd().openFile(s.io, path, .{}) catch |err| {
const status: u16 = if (err == error.FileNotFound) 404 else 403;
s.access(req, status, 0);
return clienterror(out, status, if (status == 404) "Tiny couldn't find this file" else "Tiny couldn't read the file");
};
defer file.close(s.io);
const stat = try file.stat(s.io);
if (stat.kind != .file) {
s.access(req, 403, 0);
return clienterror(out, 403, "Tiny couldn't read the file");
}
const size: usize = @intCast(stat.size);
try request.writeResponseHead(out, 200, request.contentType(path), size);
s.access(req, 200, size);
// HEAD это GET без тела: заголовки те же, Content-length честный.
if (req.method == .HEAD or size == 0) return;
try out.flush();
const mapped = try posix.mmap(null, size, .{ .READ = true }, .{ .TYPE = .PRIVATE }, file.handle, 0);
defer posix.munmap(mapped);
try out.writeAll(mapped);
}
/// CGI: ребёнок получает аргументы в `QUERY_STRING`, метод в
/// `REQUEST_METHOD`, тело POST в `stdin`, а его stdout это сокет.
/// Всё, что нужно ребёнку, готовится до `fork`: после него можно
/// звать только async-signal-safe функции, аллокатор к ним не относится.
fn serveDynamic(s: *Server, arena: std.mem.Allocator, out: *Io.Writer, connfd: c.fd_t, req: *const Request, path: []const u8, cgiargs: []const u8) !void {
const path_z = try arena.dupeZ(u8, path);
// Есть ли программа и можно ли её запустить: 404 и 403 до статус-строки
// и fork, а не 200 и обрыв после неудачного execve. Как в книге:
// обычный файл с битом исполнения для владельца (S_IXUSR).
const stat = Io.Dir.cwd().statFile(s.io, path, .{}) catch {
s.access(req, 404, 0);
return clienterror(out, 404, "Tiny couldn't find this file");
};
if (stat.kind != .file or stat.permissions.toMode() & posix.S.IXUSR == 0) {
s.access(req, 403, 0);
return clienterror(out, 403, "Tiny couldn't run the CGI program");
}
const argv = try arena.allocSentinel(?[*:0]const u8, 1, null);
argv[0] = path_z;
const envp = try arena.allocSentinel(?[*:0]const u8, 3, null);
envp[0] = try std.fmt.allocPrintSentinel(arena, "QUERY_STRING={s}", .{cgiargs}, 0);
envp[1] = try std.fmt.allocPrintSentinel(arena, "REQUEST_METHOD={s}", .{req.method_text}, 0);
envp[2] = try std.fmt.allocPrintSentinel(arena, "CONTENT_LENGTH={d}", .{req.body.len}, 0);
// Тело POST уходит ребёнку через пайп в stdin. Родитель пишет его
// после fork, поэтому тело не больше max_body: слишком большое
// могло бы забить пайп раньше, чем ребёнок начнёт читать.
var body_pipe: [2]c.fd_t = .{ -1, -1 };
if (c.pipe(&body_pipe) != 0) return error.PipeFailed;
// Первая половина заголовка от нас, остальное допишет CGI.
try out.writeAll("HTTP/1.0 200 OK\r\nServer: Tiny Web Server\r\n");
// Буфер обязан уйти в сокет до fork: иначе ребёнок пишет свою
// часть раньше нашей, а копия буфера в ребёнке пропадает.
try out.flush();
const pid = c.fork();
if (pid < 0) {
_ = c.close(body_pipe[0]);
_ = c.close(body_pipe[1]);
return error.ForkFailed;
}
if (pid == 0) {
// Ребёнок: stdin из пайпа, stdout в сокет, и образ CGI-программы.
_ = c.dup2(body_pipe[0], 0);
_ = c.dup2(connfd, 1);
_ = c.close(body_pipe[0]);
_ = c.close(body_pipe[1]);
_ = c.execve(path_z, argv.ptr, envp.ptr);
c._exit(127);
}
_ = c.close(body_pipe[0]);
fdio.writen(body_pipe[1], req.body) catch {};
_ = c.close(body_pipe[1]);
var status: c_int = 0;
const waited = while (true) {
if (c.waitpid(pid, &status, 0) >= 0) break true;
if (posix.errno(@as(c_int, -1)) != .INTR) break false;
};
// Строка лога пишется после waitpid: в ней видно, чем кончился CGI.
// `exit 127` значит, что execve не удался и ребёнок вышел сам.
s.accessCgi(req, if (waited) @bitCast(status) else null);
}
fn access(s: *Server, req: *const Request, status: u16, bytes: usize) void {
s.log("{s} \"{s} {s} {s}\" {d} {d}", .{ req.peer, req.method_text, req.uri, req.version, status, bytes });
}
/// Строка лога CGI: байтов тела сервер не видит (их пишет ребёнок),
/// зато знает код выхода или сигнал.
fn accessCgi(s: *Server, req: *const Request, wait_status: ?u32) void {
const line = "{s} \"{s} {s} {s}\" 200 0 cgi ";
const args = .{ req.peer, req.method_text, req.uri, req.version };
const st = wait_status orelse return s.log(line ++ "unknown", args);
if (c.W.IFEXITED(st)) {
s.log(line ++ "exit {d}", args ++ .{c.W.EXITSTATUS(st)});
} else {
s.log(line ++ "signal {d}", args ++ .{@intFromEnum(c.W.TERMSIG(st))});
}
}
fn log(s: *Server, comptime fmt: []const u8, args: anytype) void {
if (s.options.quiet) return;
var buf: [1024]u8 = undefined;
// Длинная строка (URI на 414) обрезается, но конец строки остаётся:
// иначе следующая запись в логе приклеилась бы к ней.
const text = std.fmt.bufPrint(&buf, fmt ++ "\n", args) catch blk: {
@memcpy(buf[buf.len - 4 ..], "...\n");
break :blk buf[0..];
};
fdio.writen(2, text) catch {};
}
};
/// `clienterror` из книги: короткая HTML-страница со статусом и причиной.
pub fn clienterror(out: *Io.Writer, status: u16, cause: []const u8) Io.Writer.Error!void {
var body_buf: [1024]u8 = undefined;
const body = std.fmt.bufPrint(&body_buf,
\\<html><title>Tiny Error</title><body bgcolor="ffffff">
\\{d}: {s}
\\<p>{s}
\\<hr><em>The Tiny Web server</em>
\\</body></html>
\\
, .{ status, request.statusText(status), cause }) catch unreachable;
try request.writeResponseHead(out, status, "text/html", body.len);
try out.writeAll(body);
}
/// Обработчик по умолчанию для `SIGPIPE`: убить процесс. Сервер, который
/// пишет в сокет закрытого клиентом соединения, получил бы сигнал вместо
/// `EPIPE`. Ставим `SIG_IGN`, и `write` честно возвращает ошибку.
pub fn ignoreSigpipe() void {
const action: posix.Sigaction = .{
.handler = .{ .handler = posix.SIG.IGN },
.mask = posix.sigemptyset(),
.flags = 0,
};
posix.sigaction(.PIPE, &action, null);
}
Пройди глазами doit ещё раз и сверь с таблицей кодов выше: каждая строка таблицы это один return clienterror(...). access стоит рядом с каждым из них, кроме самых ранних отказов (400 до разбора запроса, 413 и 431): у них нет разобранного Request, из которого строится строка лога.
adder.zig: числа из тела POST
adder из прошлого урока брал числа только из QUERY_STRING. Теперь при POST он читает тело:
//! CGI-программа adder из главы 11: складывает два числа из `QUERY_STRING`
-//! вида `15&213`. Печатает вторую половину заголовка ответа (первую уже
-//! отправил TINY) и тело. При `REQUEST_METHOD=HEAD` тела нет.
+//! вида `15&213`, а при `REQUEST_METHOD=POST` из тела запроса: ровно
+//! `CONTENT_LENGTH` байт из stdin (домашнее 11.12). Печатает вторую половину
+//! заголовка ответа (первую уже отправил TINY) и тело. При `HEAD` тела нет.
//! Собирается в `zig-out/cgi-bin/adder`, сервер запускает его через `execve`.
@@
const method: []const u8 = if (c.getenv("REQUEST_METHOD")) |m| std.mem.span(m) else "GET";
+ // Аргументы POST приходят в теле. Больше буфера не читаем: двум числам
+ // хватает, а читать ровно CONTENT_LENGTH нужно, чтобы не ждать EOF.
+ var post_buf: [256]u8 = undefined;
+ const args = if (std.mem.eql(u8, method, "POST")) try readBody(init.io, &post_buf) else query;
+
var body_buf: [1024]u8 = undefined;
- const body = render(query, &body_buf);
+ const body = render(args, &body_buf);
var out_buf: [2048]u8 = undefined;
@@
}
+/// Тело POST: `CONTENT_LENGTH` байт из stdin, не больше `buf`.
+fn readBody(io: std.Io, buf: []u8) ![]const u8 {
+ const text: []const u8 = if (c.getenv("CONTENT_LENGTH")) |l| std.mem.span(l) else "0";
+ const length = @min(std.fmt.parseInt(usize, text, 10) catch 0, buf.len);
+ var in_buf: [256]u8 = undefined;
+ var stdin = std.Io.File.stdin().readerStreaming(io, &in_buf);
+ stdin.interface.readSliceAll(buf[0..length]) catch |err| switch (err) {
+ error.EndOfStream => return buf[0..0],
+ error.ReadFailed => return err,
+ };
+ return buf[0..length];
+}
+
/// Тело ответа. Кривые аргументы дают нули, как `atoi` в книге, только
/// без неопределённого поведения.
readBody читает ровно CONTENT_LENGTH байт, но не больше своего буфера на 256 байт: двум числам хватит. Тело длиннее останется в пайпе непрочитанным, adder ответит и выйдет, а запись сервера в пайп получит EPIPE, который сервер игнорирует.
main.zig и root.zig
Подкоманда tiny <port> [root] поднимает сервер. Корень по умолчанию текущий каталог, как ./ в книге. Порт 0 значит «любой свободный», настоящий сервер печатает в первой строке stderr: так тесты находят его, не угадывая номер.
//! Точка входа: подкоманды `tiny`, `hostinfo`, `echoserver`, `echoclient`.
//! Вся работа живёт в модуле `tiny`, здесь только разбор командной строки.
const std = @import("std");
const tiny = @import("tiny");
const usage =
\\tiny, веб-сервер из главы 11 на Zig
\\
\\Использование:
\\ tiny tiny <port> [root] веб-сервер, корень статики и cgi-bin (по умолчанию .)
\\ tiny hostinfo <name> все адреса имени через getaddrinfo
\\ tiny hostinfo --std <name> то же через std.Io.net.HostName
\\ tiny echoserver <port> эхо-сервер, одно соединение за раз
\\ tiny echoserver --std <port> то же на std.Io.net
\\ tiny echoclient <host> <port> эхо-клиент: строки из stdin туда и обратно
\\
;
pub fn main(init: std.process.Init) !void {
const arena = init.arena.allocator();
const io = init.io;
const args = try init.minimal.args.toSlice(arena);
var out_buf: [4096]u8 = undefined;
var stdout = std.Io.File.stdout().writerStreaming(io, &out_buf);
const out = &stdout.interface;
var err_buf: [4096]u8 = undefined;
var stderr = std.Io.File.stderr().writerStreaming(io, &err_buf);
const log = &stderr.interface;
if (args.len < 2) return fail(out, usage);
const command = args[1];
const rest = args[2..];
if (std.mem.eql(u8, command, "tiny")) {
if (rest.len < 1) return fail(out, usage);
const port = std.fmt.parseInt(u16, rest[0], 10) catch return fail(out, "tiny: порт это число от 0 до 65535\n");
const root = if (rest.len > 1) rest[1] else ".";
var server: tiny.Server = try .init(init.gpa, io, .{ .port = port, .root = root });
defer server.deinit();
try log.print("tiny: listening on port {d}, root {s}\n", .{ server.port, root });
try log.flush();
try server.serveForever();
} else if (std.mem.eql(u8, command, "hostinfo")) {
const use_std = rest.len > 0 and std.mem.eql(u8, rest[0], "--std");
const names = if (use_std) rest[1..] else rest;
if (names.len != 1) return fail(out, usage);
if (use_std) {
try tiny.hostinfo.lookupStd(io, names[0], out);
} else {
tiny.hostinfo.hostinfo(names[0], out) catch |err| switch (err) {
error.LookupFailed => {
try out.flush();
std.process.exit(1);
},
else => return err,
};
}
try out.flush();
} else if (std.mem.eql(u8, command, "echoserver")) {
const use_std = rest.len > 0 and std.mem.eql(u8, rest[0], "--std");
const ports = if (use_std) rest[1..] else rest;
if (ports.len != 1) return fail(out, usage);
const port = std.fmt.parseInt(u16, ports[0], 10) catch return fail(out, "tiny: порт это число от 0 до 65535\n");
if (use_std) {
var server = try tiny.echo_std.listen(io, port);
defer server.deinit(io);
try log.print("echoserver (std): listening on {f}\n", .{server.socket.address});
try log.flush();
try tiny.echo_std.serve(io, &server, 0, log);
} else {
const listenfd = try tiny.socket.openListenfd(port);
defer tiny.socket.close(listenfd);
try log.print("echoserver: listening on port {d}\n", .{tiny.socket.localPort(listenfd) orelse port});
try log.flush();
try tiny.echo.serve(listenfd, 0, log);
}
} else if (std.mem.eql(u8, command, "echoclient")) {
if (rest.len != 2) return fail(out, usage);
const port = std.fmt.parseInt(u16, rest[1], 10) catch return fail(out, "tiny: порт это число от 0 до 65535\n");
var in_buf: [8192]u8 = undefined;
var stdin = std.Io.File.stdin().readerStreaming(io, &in_buf);
try tiny.echo.client(rest[0], port, &stdin.interface, out);
} else {
return fail(out, usage);
}
}
fn fail(out: *std.Io.Writer, text: []const u8) !void {
try out.writeAll(text);
try out.flush();
std.process.exit(2);
}
В src/root.zig появился модуль server и короткие имена для тех, кто подключает tiny как библиотеку:
//! Корень модуля `tiny`. Программа, тесты шагов и our-runner берут части
//! сервера отсюда. Шаги: 63 `addr` и `hostinfo`, 64 `socket`, `echo`,
//! `echo_std` и `fdio`, 65 `request`, 66 `server`.
pub const addr = @import("net/addr.zig");
pub const hostinfo = @import("net/hostinfo.zig");
pub const fdio = @import("net/fdio.zig");
pub const socket = @import("net/socket.zig");
pub const echo = @import("net/echo.zig");
pub const echo_std = @import("net/echo_std.zig");
pub const request = @import("http/request.zig");
pub const server = @import("http/tiny.zig");
// Публичное API для других пакетов: our-runner добавляет маршрут POST /run.
pub const Server = server.Server;
pub const Request = server.Request;
pub const Response = server.Response;
pub const Route = server.Route;
pub const Options = server.Options;
pub const Method = request.Method;
build.zig
Тестам шага нужен путь к собранной программе tiny: один тест запускает её настоящим процессом, чтобы прочитать лог. И шаг попадает в список:
/// Номера уроков курса, на которых проект вырос. Каждому шагу
/// соответствует файл `tests/step_NN.zig`, и все они зелёные на финале.
-const project_steps = [_]u8{ 63, 64, 65 };
+const project_steps = [_]u8{ 63, 64, 65, 66 };
pub fn build(b: *std.Build) void {
@@
options.addOptionPath("adder_exe", adder.getEmittedBin());
options.addOptionPath("www_dir", b.path("www"));
+ options.addOptionPath("tiny_exe", exe.getEmittedBin());
// zig build test прогоняет все шаги, zig build test -Dstep=64 только один.
Статика из www/ уже ставится в корень zig-out рядом с cgi-bin/adder (это мы сделали в прошлом уроке), поэтому tiny 8066 zig-out отдаёт всё из одного каталога. В www/ три файла: home.html из книги, картинка dot.png на 74 байта (PNG 8 на 8 точек) и hello.txt со строкой hello from tiny.
<html>
<head><title>test</title></head>
<body>
<img align="middle" src="dot.png">
Dave O'Hallaron
</body>
</html>
Прогон
macOS 26, Apple Silicon. Корень это копия zig-out плюс файлы для опытов: big.bin и clip.mp4, а в cgi-bin рядом с adder лежат noexec (сценарий без бита исполнения) и broken (бит исполнения есть, внутри текст).
$ tiny tiny 8066 .
tiny: listening on port 8066, root .
В другом терминале:
$ curl -i http://localhost:8066/home.html
HTTP/1.0 200 OK
Server: Tiny Web Server
Connection: close
Content-length: 114
Content-type: text/html
<html>
<head><title>test</title></head>
<body>
<img align="middle" src="dot.png">
Dave O'Hallaron
</body>
</html>
$ curl -i 'http://localhost:8066/cgi-bin/adder?15&213'
HTTP/1.0 200 OK
Server: Tiny Web Server
Connection: close
Content-length: 107
Content-type: text/html
Welcome to add.com: THE Internet addition portal.
<p>The answer is: 15 + 213 = 228
<p>Thanks for visiting!
$ curl -I http://localhost:8066/dot.png
HTTP/1.0 200 OK
Server: Tiny Web Server
Connection: close
Content-length: 74
Content-type: image/png
$ curl -i --data '2&40' http://localhost:8066/cgi-bin/adder
HTTP/1.0 200 OK
Server: Tiny Web Server
Connection: close
Content-length: 104
Content-type: text/html
Welcome to add.com: THE Internet addition portal.
<p>The answer is: 2 + 40 = 42
<p>Thanks for visiting!
$ curl -si http://localhost:8066/cgi-bin/noexec | head -1
HTTP/1.0 403 Forbidden
$ curl -si http://localhost:8066/cgi-bin/broken
HTTP/1.0 200 OK
Server: Tiny Web Server
$ curl -si http://localhost:8066/cgi-bin/nope | head -1
HTTP/1.0 404 Not Found
$ curl -si -X DELETE http://localhost:8066/home.html | head -1
HTTP/1.0 501 Not Implemented
$ curl -si "http://localhost:8066/cgi-bin/adder?$(printf '1%.0s' $(seq 5000))" | head -1
HTTP/1.0 414 URI Too Long
Лог сервера за тот же сеанс, вместе с big.bin и clip.mp4 из разделов выше:
tiny: listening on port 8066, root .
127.0.0.1:62946 "GET /home.html HTTP/1.1" 200 114
127.0.0.1:62949 "GET /cgi-bin/adder?15&213 HTTP/1.1" 200 0 cgi exit 0
127.0.0.1:62951 "HEAD /dot.png HTTP/1.1" 200 74
127.0.0.1:62953 "HEAD /clip.mp4 HTTP/1.1" 200 1048576
127.0.0.1:62956 "POST /cgi-bin/adder HTTP/1.1" 200 0 cgi exit 0
127.0.0.1:62958 "GET /big.bin HTTP/1.1" 200 67108864
127.0.0.1:62958 transaction failed: WriteFailed
127.0.0.1:62960 "GET /cgi-bin/noexec HTTP/1.1" 403 0
127.0.0.1:62962 "GET /cgi-bin/broken HTTP/1.1" 200 0 cgi exit 127
127.0.0.1:62964 "GET /cgi-bin/nope HTTP/1.1" 404 0
127.0.0.1:62966 "DELETE /home.html HTTP/1.1" 501 0
127.0.0.1:62968 "GET /cgi-bin/adder?1111111111111111...1111111111111111...
127.0.0.1:62970 "GET /hello.txt HTTP/1.1" 200 16
Строку с 414 мы сократили посередине. Настоящая длиннее: ровно 1024 байта вместе с переводом строки, и кончается она на .... Это log обрезал URI из пяти тысяч единиц по своему буферу, но конец строки сохранил, иначе следующая запись приклеилась бы к этой.
Разница в Content-length у двух ответов adder, 107 и 104, это длина тела: 15 + 213 = 228 на три символа длиннее, чем 2 + 40 = 42. Ответ на broken оборван после двух строк сервера, а лог объясняет почему: cgi exit 127, execve не удался. Ответ на noexec отбит раньше статус-строки, и клиент получил настоящую страницу 403.
Тесты шага
Тестировать сервер удобнее всего в том же процессе: сервер крутится в отдельном потоке и обслуживает ровно столько соединений, сколько тест собирается открыть, а клиент на обычном сокете из урока 64 шлёт сырой запрос и читает ответ до закрытия соединения. Всё это живёт в tests/support.zig:
//! Общее для интеграционных тестов: сервер в отдельном потоке, клиент
//! на libc-сокете в том же процессе, разбор ответа HTTP.
const std = @import("std");
const tiny = @import("tiny");
const build_options = @import("build_options");
pub const adder_exe = build_options.adder_exe;
pub const www_dir = build_options.www_dir;
pub const tiny_exe = build_options.tiny_exe;
/// Корень для сервера в тестах: копия `www/` плюс `cgi-bin/adder`.
pub const Root = struct {
tmp: std.testing.TmpDir,
path: []const u8,
pub fn create(arena: std.mem.Allocator) !Root {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
errdefer tmp.cleanup();
const www = try std.Io.Dir.cwd().openDir(io, www_dir, .{});
defer www.close(io);
for ([_][]const u8{ "home.html", "dot.png", "hello.txt" }) |name| {
try www.copyFile(name, tmp.dir, name, io, .{});
}
try tmp.dir.createDirPath(io, "cgi-bin");
try std.Io.Dir.cwd().copyFile(adder_exe, tmp.dir, "cgi-bin/adder", io, .{});
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const n = try tmp.dir.realPath(io, &path_buf);
const path = try arena.dupe(u8, path_buf[0..n]);
return .{ .tmp = tmp, .path = path };
}
pub fn cleanup(root: *Root) void {
root.tmp.cleanup();
}
};
/// Сервер в потоке: обслуживает ровно `n` соединений и выходит.
pub const Background = struct {
server: tiny.Server,
thread: std.Thread,
/// Заполняет `bg` на месте: поток получает адрес `bg.server`, поэтому
/// сервер обязан жить там, где его увидит поток, а не в локальной
/// копии, которую вернули бы по значению.
pub fn start(bg: *Background, gpa: std.mem.Allocator, options: tiny.Options, n: usize) !void {
bg.server = try .init(gpa, std.testing.io, options);
bg.thread = try std.Thread.spawn(.{}, serveN, .{ &bg.server, n });
}
fn serveN(server: *tiny.Server, n: usize) void {
for (0..n) |_| server.serveOnce() catch |err| std.debug.print("serveOnce: {t}\n", .{err});
}
pub fn join(bg: *Background) void {
bg.thread.join();
bg.server.deinit();
}
};
pub const Reply = struct {
status: u16,
head: []const u8,
body: []const u8,
pub fn header(reply: Reply, name: []const u8) ?[]const u8 {
var lines = std.mem.splitSequence(u8, reply.head, "\r\n");
while (lines.next()) |line| {
const h = tiny.request.splitHeader(line) catch continue;
if (std.ascii.eqlIgnoreCase(h.name, name)) return h.value;
}
return null;
}
};
/// Отправляет сырой запрос, читает ответ до закрытия соединения.
pub fn exchange(arena: std.mem.Allocator, port: u16, raw: []const u8) !Reply {
const fd = try tiny.socket.openClientfd("127.0.0.1", port);
defer tiny.socket.close(fd);
try tiny.fdio.writen(fd, raw);
const response = try readAll(arena, fd);
return parseReply(response);
}
pub fn readAll(arena: std.mem.Allocator, fd: std.c.fd_t) ![]const u8 {
var list: std.ArrayList(u8) = .empty;
var chunk: [4096]u8 = undefined;
while (true) {
const n = try tiny.fdio.readn(fd, &chunk);
try list.appendSlice(arena, chunk[0..n]);
if (n < chunk.len) break;
}
return list.items;
}
pub fn parseReply(response: []const u8) !Reply {
const split = std.mem.indexOf(u8, response, "\r\n\r\n") orelse return error.NoHeaderEnd;
const head = response[0..split];
const status_line = std.mem.sliceTo(head, '\r');
var words = std.mem.tokenizeScalar(u8, status_line, ' ');
_ = words.next() orelse return error.BadStatusLine;
const status = try std.fmt.parseInt(u16, words.next() orelse return error.BadStatusLine, 10);
return .{ .status = status, .head = head, .body = response[split + 4 ..] };
}
pub fn get(arena: std.mem.Allocator, port: u16, uri: []const u8) !Reply {
const raw = try std.fmt.allocPrint(arena, "GET {s} HTTP/1.0\r\nHost: localhost\r\n\r\n", .{uri});
return exchange(arena, port, raw);
}
Присмотрись к Background.start. Он принимает bg: *Background и заполняет структуру на месте, а не возвращает новую по значению. Первая версия эталона выглядела естественнее: fn start(...) !Background, внутри var bg: Background = ..., поток получает &bg.server, функция возвращает bg. И тесты зависали. Возврат по значению копирует структуру в переменную вызывающего, а поток держит указатель на старую копию, на стеке функции, которая уже вернулась. Следующий же вызов затирает этот кусок стека, и поток крутит serveOnce на мусоре вместо слушающего дескриптора. Правило простое: если кто-то получил адрес поля структуры, структуру больше нельзя перемещать. Zig здесь не спасает, это та же ловушка, что и в C, только без malloc, который обычно её прячет.
Сами тесты шага:
//! Шаг 66: TINY целиком. Сервер в потоке на свободном порту, клиент
//! на libc-сокете: статика, CGI, HEAD, POST, 404, 501, 403, свой маршрут
//! для zbox и обрыв клиента без смерти сервера.
const std = @import("std");
const tiny = @import("tiny");
const support = @import("support.zig");
const testing = std.testing;
const Fixture = struct {
arena_state: std.heap.ArenaAllocator,
root: support.Root,
bg: support.Background,
fn start(n: usize, routes: []const tiny.Route) !*Fixture {
const f = try testing.allocator.create(Fixture);
f.arena_state = .init(testing.allocator);
f.root = try .create(f.arena_state.allocator());
try f.bg.start(testing.allocator, .{ .port = 0, .root = f.root.path, .routes = routes, .quiet = true, .max_body = 4096 }, n);
return f;
}
fn port(f: *Fixture) u16 {
return f.bg.server.port;
}
fn arena(f: *Fixture) std.mem.Allocator {
return f.arena_state.allocator();
}
fn finish(f: *Fixture) void {
f.bg.join();
f.root.cleanup();
f.arena_state.deinit();
testing.allocator.destroy(f);
}
};
test "GET /home.html: 200, тип text/html, тело файла целиком" {
const f = try Fixture.start(1, &.{});
defer f.finish();
const reply = try support.get(f.arena(), f.port(), "/home.html");
try testing.expectEqual(200, reply.status);
try testing.expectEqualStrings("text/html", reply.header("Content-type").?);
try testing.expectEqualStrings("Tiny Web Server", reply.header("Server").?);
try testing.expectEqualStrings("close", reply.header("Connection").?);
try testing.expectEqual(reply.body.len, try std.fmt.parseInt(usize, reply.header("Content-length").?, 10));
try testing.expect(std.mem.indexOf(u8, reply.body, "Dave O'Hallaron") != null);
}
test "GET /: корень отдаёт home.html; двоичный файл уходит байт в байт" {
const f = try Fixture.start(2, &.{});
defer f.finish();
const home = try support.get(f.arena(), f.port(), "/");
try testing.expectEqual(200, home.status);
try testing.expect(std.mem.indexOf(u8, home.body, "<title>test</title>") != null);
const png = try support.get(f.arena(), f.port(), "/dot.png");
try testing.expectEqual(200, png.status);
try testing.expectEqualStrings("image/png", png.header("Content-type").?);
const original = try std.Io.Dir.cwd().readFileAlloc(testing.io, support.www_dir ++ "/dot.png", f.arena(), .limited(1 << 16));
try testing.expectEqualSlices(u8, original, png.body);
}
test "GET /cgi-bin/adder?15&213: ответ собирают сервер и CGI вместе" {
const f = try Fixture.start(1, &.{});
defer f.finish();
const reply = try support.get(f.arena(), f.port(), "/cgi-bin/adder?15&213");
try testing.expectEqual(200, reply.status);
try testing.expectEqualStrings("Tiny Web Server", reply.header("Server").?);
try testing.expectEqualStrings("text/html", reply.header("Content-type").?);
try testing.expect(std.mem.indexOf(u8, reply.body, "15 + 213 = 228") != null);
try testing.expectEqual(reply.body.len, try std.fmt.parseInt(usize, reply.header("Content-length").?, 10));
}
test "HEAD: те же заголовки, тела нет" {
const f = try Fixture.start(2, &.{});
defer f.finish();
const static = try support.exchange(f.arena(), f.port(), "HEAD /home.html HTTP/1.0\r\n\r\n");
try testing.expectEqual(200, static.status);
try testing.expectEqual(0, static.body.len);
try testing.expectEqual(114, try std.fmt.parseInt(usize, static.header("Content-length").?, 10));
const dynamic = try support.exchange(f.arena(), f.port(), "HEAD /cgi-bin/adder?1&2 HTTP/1.0\r\n\r\n");
try testing.expectEqual(200, dynamic.status);
try testing.expectEqual(0, dynamic.body.len);
try testing.expectEqualStrings("text/html", dynamic.header("Content-type").?);
}
test "POST /cgi-bin/adder: аргументы в теле, CGI читает CONTENT_LENGTH байт из stdin" {
const f = try Fixture.start(2, &.{});
defer f.finish();
const reply = try support.exchange(f.arena(), f.port(), "POST /cgi-bin/adder HTTP/1.0\r\nContent-Length: 4\r\n\r\n2&40");
try testing.expectEqual(200, reply.status);
try testing.expect(std.mem.indexOf(u8, reply.body, "2 + 40 = 42") != null);
// У POST аргументы только в теле: хвост после ? не считается.
const both = try support.exchange(f.arena(), f.port(), "POST /cgi-bin/adder?1&1 HTTP/1.0\r\nContent-Length: 5\r\n\r\n15&27");
try testing.expect(std.mem.indexOf(u8, both.body, "15 + 27 = 42") != null);
}
test "CGI, который нельзя запустить: 403 до статус-строки, а не 200 и обрыв" {
const f = try Fixture.start(2, &.{});
defer f.finish();
const io = testing.io;
try f.root.tmp.dir.writeFile(io, .{ .sub_path = "cgi-bin/noexec", .data = "#!/bin/sh\necho hi\n" });
try f.root.tmp.dir.createDirPath(io, "cgi-bin/dir");
const noexec = try support.get(f.arena(), f.port(), "/cgi-bin/noexec");
try testing.expectEqual(403, noexec.status);
try testing.expect(std.mem.indexOf(u8, noexec.body, "Tiny couldn't run the CGI program") != null);
try testing.expectEqual(403, (try support.get(f.arena(), f.port(), "/cgi-bin/dir")).status);
}
test "URI длиннее буфера это 414" {
const f = try Fixture.start(1, &.{});
defer f.finish();
const uri = try std.fmt.allocPrint(f.arena(), "/cgi-bin/adder?{s}", .{"1" ** 5000});
const reply = try support.get(f.arena(), f.port(), uri);
try testing.expectEqual(414, reply.status);
try testing.expectEqualStrings("414", reply.head[9..12]);
try testing.expect(std.mem.startsWith(u8, reply.head, "HTTP/1.0 414 URI Too Long\r\n"));
}
test "лог: код выхода CGI (провал execve это exit 127), длинная строка не склеивается" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const io = testing.io;
var root: support.Root = try .create(arena);
defer root.cleanup();
// Бит исполнения есть, а исполнять нечего: execve отвечает ENOEXEC.
try root.tmp.dir.writeFile(io, .{ .sub_path = "cgi-bin/broken", .data = "not a program\n", .flags = .{ .permissions = .executable_file } });
// Настоящий tiny: лог это его stderr, первая строка называет порт.
var child = try std.process.spawn(io, .{
.argv = &.{ support.tiny_exe, "tiny", "0", root.path },
.stdin = .ignore,
.stdout = .ignore,
.stderr = .pipe,
});
defer child.kill(io);
var log_buf: [4096]u8 = undefined;
var log = child.stderr.?.readerStreaming(io, &log_buf);
const first = try log.interface.takeDelimiterInclusive('\n');
const prefix = "tiny: listening on port ";
try testing.expect(std.mem.startsWith(u8, first, prefix));
// takeDelimiterInclusive: строка вместе с `\n`, иначе следующее чтение
// упрётся в тот же, не съеденный разделитель.
const comma = std.mem.indexOfScalar(u8, first, ',').?;
const port = try std.fmt.parseInt(u16, first[prefix.len..comma], 10);
_ = try support.get(arena, port, "/cgi-bin/adder?1&2");
try testing.expect(std.mem.endsWith(u8, try log.interface.takeDelimiterInclusive('\n'), "\"GET /cgi-bin/adder?1&2 HTTP/1.0\" 200 0 cgi exit 0\n"));
// Ответ оборван после первых двух строк: остальное должен был дописать CGI.
const fd = try tiny.socket.openClientfd("127.0.0.1", port);
defer tiny.socket.close(fd);
try tiny.fdio.writen(fd, "GET /cgi-bin/broken HTTP/1.0\r\n\r\n");
try testing.expectEqualStrings("HTTP/1.0 200 OK\r\nServer: Tiny Web Server\r\n", try support.readAll(arena, fd));
try testing.expect(std.mem.endsWith(u8, try log.interface.takeDelimiterInclusive('\n'), "\"GET /cgi-bin/broken HTTP/1.0\" 200 0 cgi exit 127\n"));
// Строка длиннее буфера лога обрезается с `...`, следующая не приклеивается.
const long_uri = try std.fmt.allocPrint(arena, "/cgi-bin/adder?{s}", .{"1" ** 5000});
// Оба запроса до чтения лога: без `\n` две записи слиплись бы в одну
// строку, и тест упал бы на длине, а не завис в ожидании.
try testing.expectEqual(414, (try support.get(arena, port, long_uri)).status);
_ = try support.get(arena, port, "/hello.txt");
const long_line = try log.interface.takeDelimiterInclusive('\n');
try testing.expectEqual(1024, long_line.len);
try testing.expect(std.mem.endsWith(u8, long_line, "1111...\n"));
const next = try log.interface.takeDelimiterInclusive('\n');
try testing.expect(std.mem.startsWith(u8, next, "127.0.0.1:"));
try testing.expect(std.mem.endsWith(u8, next, "\"GET /hello.txt HTTP/1.0\" 200 16\n"));
}
test "404, 501, 403 и 400 приходят страницей clienterror" {
const f = try Fixture.start(5, &.{});
defer f.finish();
const missing = try support.get(f.arena(), f.port(), "/nope.html");
try testing.expectEqual(404, missing.status);
try testing.expect(std.mem.indexOf(u8, missing.body, "Tiny couldn't find this file") != null);
const missing_cgi = try support.get(f.arena(), f.port(), "/cgi-bin/nope?1&2");
try testing.expectEqual(404, missing_cgi.status);
const put = try support.exchange(f.arena(), f.port(), "PUT /home.html HTTP/1.0\r\n\r\n");
try testing.expectEqual(501, put.status);
try testing.expect(std.mem.indexOf(u8, put.body, "Not Implemented") != null);
const escape = try support.get(f.arena(), f.port(), "/../build.zig");
try testing.expectEqual(403, escape.status);
const garbage = try support.exchange(f.arena(), f.port(), "hello?\r\n\r\n");
try testing.expectEqual(400, garbage.status);
}
test "тело больше max_body это 413" {
const f = try Fixture.start(1, &.{});
defer f.finish();
const reply = try support.exchange(f.arena(), f.port(), "POST /cgi-bin/adder HTTP/1.0\r\nContent-Length: 100000\r\n\r\n");
try testing.expectEqual(413, reply.status);
}
fn runHandler(arena: std.mem.Allocator, req: *const tiny.Request) anyerror!tiny.Response {
const body = try std.fmt.allocPrint(arena, "{{\"method\":\"{s}\",\"path\":\"{s}\",\"query\":\"{s}\",\"len\":{d},\"host\":\"{s}\",\"echo\":\"{s}\"}}", .{
req.method_text, req.path, req.query, req.body.len, req.headers.host orelse "-", req.body,
});
return .{ .content_type = "application/json", .body = body };
}
fn failingHandler(_: std.mem.Allocator, _: *const tiny.Request) anyerror!tiny.Response {
return error.Boom;
}
test "свой маршрут: POST /run получает разобранный запрос, ошибка обработчика это 500" {
const routes = [_]tiny.Route{
.{ .method = .POST, .path = "/run", .handle = runHandler },
.{ .method = .GET, .path = "/boom", .handle = failingHandler },
};
const f = try Fixture.start(3, &routes);
defer f.finish();
const run = try support.exchange(f.arena(), f.port(), "POST /run?lang=zig HTTP/1.1\r\nHost: tiny.test\r\nContent-Length: 11\r\n\r\nconst x = 1");
try testing.expectEqual(200, run.status);
try testing.expectEqualStrings("application/json", run.header("Content-type").?);
try testing.expectEqualStrings("{\"method\":\"POST\",\"path\":\"/run\",\"query\":\"lang=zig\",\"len\":11,\"host\":\"tiny.test\",\"echo\":\"const x = 1\"}", run.body);
// GET /run маршрутом не покрыт: это обычная статика, файла нет.
const get_run = try support.get(f.arena(), f.port(), "/run");
try testing.expectEqual(404, get_run.status);
const boom = try support.get(f.arena(), f.port(), "/boom");
try testing.expectEqual(500, boom.status);
}
test "serve_files = false: сервер знает только свои маршруты, остальное 404" {
const routes = [_]tiny.Route{.{ .method = .POST, .path = "/run", .handle = runHandler }};
const f = try testing.allocator.create(Fixture);
f.arena_state = .init(testing.allocator);
f.root = try .create(f.arena_state.allocator());
try f.bg.start(testing.allocator, .{ .port = 0, .root = f.root.path, .routes = &routes, .quiet = true, .serve_files = false }, 3);
defer f.finish();
// home.html в корне лежит, но статика выключена.
try testing.expectEqual(404, (try support.get(f.arena(), f.port(), "/home.html")).status);
try testing.expectEqual(404, (try support.get(f.arena(), f.port(), "/cgi-bin/adder?1&2")).status);
const run = try support.exchange(f.arena(), f.port(), "POST /run HTTP/1.0\r\nContent-Length: 2\r\n\r\n{}");
try testing.expectEqual(200, run.status);
}
test "клиент оборвал соединение: сервер жив и обслуживает следующего" {
const f = try Fixture.start(3, &.{});
defer f.finish();
// Первый клиент уходит, не дочитав ответ: серверу прилетает EPIPE
// на write, а не SIGPIPE.
const fd = try tiny.socket.openClientfd("127.0.0.1", f.port());
try tiny.fdio.writen(fd, "GET /home.html HTTP/1.0\r\n\r\n");
tiny.socket.close(fd);
// Второй вообще молчит и закрывает соединение.
const silent = try tiny.socket.openClientfd("127.0.0.1", f.port());
tiny.socket.close(silent);
const reply = try support.get(f.arena(), f.port(), "/hello.txt");
try testing.expectEqual(200, reply.status);
try testing.expectEqualStrings("hello from tiny\n", reply.body);
}
test "clienterror сам по себе: статус, тип и тело" {
var buf: [1024]u8 = undefined;
var out: std.Io.Writer = .fixed(&buf);
try tiny.server.clienterror(&out, 404, "Tiny couldn't find this file");
const reply = try support.parseReply(out.buffered());
try testing.expectEqual(404, reply.status);
try testing.expectEqualStrings("text/html", reply.header("Content-type").?);
try testing.expect(std.mem.indexOf(u8, reply.body, "404: Not Found") != null);
try testing.expectEqual(reply.body.len, try std.fmt.parseInt(usize, reply.header("Content-length").?, 10));
}
- «GET /: … двоичный файл уходит байт в байт» сравнивает
dot.pngиз ответа с файлом на диске:mmapиwritenне имеют права потерять или добавить ни байта. - «HEAD» проверяет и статику, и CGI: заголовки на месте, тела нет.
- «CGI, который нельзя запустить» и «лог: код выхода CGI» покрывают обе ветки провала: отказ до статус-строки и
exit 127после неё. Второй запускает настоящую программуtiny, потому что лог это её stderr, и заодно шлёт URI на пять тысяч символов: строка лога обязана обрезаться на 1024 байтах с..., а следующая остаться целой. - «свой маршрут» и «serve_files = false» проверяют API, на котором стоит шаг
zbox. - «клиент оборвал соединение» это
SIGPIPE: первый клиент закрывает сокет, не читая ответа, второй вообще молчит, третий обязан получить свои 200.
$ zig build test --summary all
Build Summary: 12/12 steps succeeded; 43/43 tests passed
$ zig build test -Dstep=66 --summary all
Build Summary: 6/6 steps succeeded; 14/14 tests passed
Все 43 теста зелёные и на macOS, и в контейнере с Linux (arm64).
Шаг zbox: POST /run поверх TINY
До сих пор zbox запускался из командной строки: zbox run --time-ms 300 ./prog печатал одну строку JSON. Раннер курса устроен иначе: сайт присылает исходник по HTTP, раннер собирает его, запускает в песочнице и отвечает JSON-ом. Сегодня zbox получит такой же вход. zbox serve <port> поднимает TINY с одним маршрутом, POST /run, и без статики.
Что приходит и что уходит
Запрос это JSON не больше 256 КБ, обязательно только поле source:
{"source": "<текст main.zig>", "time_ms": 2000, "mem_mb": 64, "stdin": "1 2\n"}
sourceляжет вmain.zigвременного каталога/tmp/zbox-XXXXXX, каталог удаляется после ответа;time_msэто лимит для программы, по умолчанию 2000;mem_mbэто--mem-mbиз урока про память, только Linux и cgroups v2;stdinэто то, что программа прочитает со стандартного ввода, по умолчанию пусто;- лишние поля пропускаются.
Исходник проходит две стадии, а не одну команду zig run. Компилятору нужны секунды и сотни мегабайт, и лимит в 300 мс, заказанный для программы, убил бы его раньше, чем программа сделала первый шаг. Поэтому сначала zig build-exe main.zig со своим щедрым лимитом (60 с, 256 КБ вывода), потом собранная программа под лимитами из запроса. Обе стадии идут через ту же функцию run(), что и zbox run: fork, сторож времени, захват вывода, wait4.
Ответ 200 это ровно та строка JSON, которую печатает zbox run, плюс поле stage в конце: run, если программа собралась и запустилась, compile, если сборка не прошла. Тогда exit_code и stderr принадлежат компилятору, и ученик видит его сообщение как есть. Коды ответа:
| Код | Когда |
|---|---|
| 200 | запуск состоялся, итог в теле |
| 400 | тело не JSON, пустой source, time_ms или mem_mb равны нулю: {"error":"BadJson"}, EmptySource, BadLimit |
| 404 | любой путь или метод, кроме POST /run: статики нет |
| 413 | Content-Length больше 256 КБ, тело даже не читается |
| 500 | запуск не подготовить: не создался временный каталог, mem_mb на macOS |
Пакет по пути: build.zig.zon
Код TINY живёт в соседнем эталоне, examples/our-tiny, и копировать его в zbox мы не будем. В Zig пакет подключается через манифест build.zig.zon, и зависимость может быть не только архивом по URL с хешем, но и каталогом по пути. У our-tiny для этого есть свой манифест с именем .our_tiny и модуль tiny, объявленный через b.addModule с link_libc = true. У our-runner манифест появляется впервые:
.{
.name = .our_runner,
.version = "0.1.0",
.fingerprint = 0xa53943f87aa1e0e,
.minimum_zig_version = "0.16.0",
.dependencies = .{
// HTTP-сервер для `zbox serve` (шаг 66): TINY из соседнего эталона.
.tiny = .{ .path = "../our-tiny" },
},
.paths = .{
"build.zig",
"build.zig.zon",
"src",
"tests",
"rootfs/build.sh",
"README.md",
},
}
fingerprint генерирует сам zig: если его нет, zig build откажется собирать и подскажет значение. Это идентификатор пакета, по нему менеджер пакетов отличает форк от оригинала. paths перечисляет, что входит в пакет, если его кто-то подключит.
В build.zig зависимость превращается в модуль и попадает в импорты программы и тестов:
/// Номера уроков курса, на которых проект вырос. Каждому шагу
/// соответствует файл `tests/step_NN.zig`, и все они зелёные на финале.
-const project_steps = [_]u8{ 48, 49, 54, 61, 64 };
+const project_steps = [_]u8{ 48, 49, 54, 61, 64, 66 };
pub fn build(b: *std.Build) void {
@@
});
+ // HTTP-сервер для `zbox serve` (шаг 66): модуль `tiny` соседнего
+ // эталона our-tiny, подключён по пути в build.zig.zon.
+ const tiny = b.dependency("tiny", .{ .target = target, .optimize = optimize }).module("tiny");
+
const exe = b.addExecutable(.{
.name = "zbox",
@@
.target = target,
.optimize = optimize,
- .imports = &.{.{ .name = "zbox", .module = zbox }},
+ .imports = &.{
+ .{ .name = "zbox", .module = zbox },
+ .{ .name = "tiny", .module = tiny },
+ },
}),
});
@@
.imports = &.{
.{ .name = "zbox", .module = zbox },
+ .{ .name = "tiny", .module = tiny },
.{ .name = "build_options", .module = options.createModule() },
},
b.dependency("tiny", ...) находит в манифесте запись .tiny, собирает её build.zig с нашими target и optimize и отдаёт модуль по имени "tiny", объявленный там через b.addModule. Каталог our-tiny должен лежать рядом с our-runner: копируешь один, копируй и второй.
serve.zig: всё, кроме HTTP
Новый файл делает всё, что не касается протокола: разбирает JSON, готовит временный каталог, гоняет две стадии и печатает ответ. HTTP ему не нужен, поэтому все его части проверяются тестами без сервера.
//! `POST /run`: исходник приходит JSON-ом, уходит ответ `zbox run`.
//! Здесь всё, кроме HTTP: разбор запроса, временный каталог, сборка и запуск
//! под лимитами через ту же `run()`, печать ответа. HTTP-сервер (TINY из
//! `examples/our-tiny`) подключает это в `src/main.zig`.
//!
//! Две стадии, а не `zig run`: компилятору нужны секунды и сотни мегабайт,
//! и лимит времени в 300 мс убил бы его раньше, чем программа сделала
//! первый шаг. Поэтому `zig build-exe` идёт со своим щедрым лимитом, а
//! лимиты из запроса достаются только собранной программе. Какая стадия
//! ответила, видно по полю `stage`.
const std = @import("std");
const c = std.c;
const args = @import("args.zig");
const report = @import("report.zig");
const run_mod = @import("run.zig");
// mkdtemp есть и в glibc, и в libSystem, но не в std.c: объявляем сами.
extern "c" fn mkdtemp(template: [*:0]u8) ?[*:0]u8;
/// Тело `POST /run`. Все поля, кроме `source`, необязательные.
pub const Request = struct {
/// Текст `main.zig`.
source: []const u8 = "",
/// Лимит времени для собранной программы, миллисекунды.
time_ms: u32 = 2000,
/// Лимит памяти через cgroups, мегабайты. Только Linux и root.
mem_mb: ?u32 = null,
/// Что программа прочитает из stdin.
stdin: []const u8 = "",
};
/// Больше этого тело не принимаем: `413 Payload Too Large`.
pub const max_body = 256 * 1024;
/// Компилятору времени даём щедро, но не бесконечно.
pub const compile_ms = 60_000;
pub const ParseError = error{ BadJson, EmptySource, BadLimit, OutOfMemory };
pub fn parseRequest(arena: std.mem.Allocator, body: []const u8) ParseError!Request {
const request = std.json.parseFromSliceLeaky(Request, arena, body, .{
.ignore_unknown_fields = true,
}) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.BadJson,
};
if (request.source.len == 0) return error.EmptySource;
if (request.time_ms == 0 or request.mem_mb == 0) return error.BadLimit;
return request;
}
pub const Stage = enum { compile, run };
pub const Reply = struct {
stage: Stage,
outcome: report.Outcome,
};
pub const Error = error{ TempDirFailed, WriteFailed } || run_mod.Error;
/// Кладёт исходник во временный каталог, собирает и запускает. Каталог
/// удаляется на выходе. `zig` берётся из `ZBOX_ZIG` или из `PATH`.
pub fn runSource(arena: std.mem.Allocator, io: std.Io, request: Request) Error!Reply {
var template = "/tmp/zbox-XXXXXX".*;
const dir_z = mkdtemp(&template) orelse return error.TempDirFailed;
const dir_path = std.mem.span(dir_z);
defer std.Io.Dir.cwd().deleteTree(io, dir_path) catch {};
var dir = std.Io.Dir.openDirAbsolute(io, dir_path, .{}) catch return error.TempDirFailed;
defer dir.close(io);
dir.writeFile(io, .{ .sub_path = "main.zig", .data = request.source }) catch return error.WriteFailed;
dir.writeFile(io, .{ .sub_path = "stdin", .data = request.stdin }) catch return error.WriteFailed;
const zig = std.mem.span(c.getenv("ZBOX_ZIG") orelse "zig");
const cache = try std.fmt.allocPrint(arena, "{s}/.zig-cache", .{dir_path});
const exe = try std.fmt.allocPrint(arena, "{s}/main", .{dir_path});
const emit = try std.fmt.allocPrint(arena, "-femit-bin={s}", .{exe});
// Стадия 1: компилятор. Его вывод и есть ответ, если сборка не удалась.
const build: args.Command = .{
.time_ms = compile_ms,
.out_kb = 256,
.cwd = dir_path,
.argv = &.{ zig, "build-exe", "main.zig", emit, "--cache-dir", cache },
};
const compiled = try run_mod.run(arena, build);
if (compiled.exit_code != 0) return .{ .stage = .compile, .outcome = compiled };
// Стадия 2: программа под лимитами из запроса.
const program: args.Command = .{
.time_ms = request.time_ms,
.mem_mb = request.mem_mb,
.stdin_path = try std.fmt.allocPrint(arena, "{s}/stdin", .{dir_path}),
.cwd = dir_path,
.argv = &.{exe},
};
return .{ .stage = .run, .outcome = try run_mod.run(arena, program) };
}
/// Тот же JSON, что печатает `zbox run`, плюс поле `stage` в конце.
pub fn writeJson(out: *std.Io.Writer, reply: Reply) std.Io.Writer.Error!void {
var line: [64 * 1024 * 2 + 4096]u8 = undefined;
var fixed: std.Io.Writer = .fixed(&line);
report.writeJson(&fixed, reply.outcome) catch {};
const text = std.mem.trimEnd(u8, fixed.buffered(), "}\n");
try out.writeAll(text);
try out.print(",\"stage\":\"{t}\"}}\n", .{reply.stage});
}
pub const ArgsError = error{BadUsage};
/// `serve <port>`: порт от 0 (ядро выберет свободный) до 65535.
pub fn parsePort(argv: []const []const u8) ArgsError!u16 {
if (argv.len != 2 or !std.mem.eql(u8, argv[0], "serve")) return error.BadUsage;
return std.fmt.parseInt(u16, argv[1], 10) catch error.BadUsage;
}
Пройдём по нему сверху вниз.
parseRequest это одна функция std: std.json.parseFromSliceLeaky раскладывает JSON прямо в структуру Request по именам полей, значения по умолчанию берёт из объявления, а ignore_unknown_fields пропускает лишнее. Слово Leaky в имени значит, что строки выделяются в переданном аллокаторе и отдельно не освобождаются: у нас это арена транзакции, она уйдёт целиком. Все ошибки разбора, кроме нехватки памяти, склеиваются в одну BadJson: клиенту незачем знать, на каком символе споткнулся парсер.
mkdtemp создаёт каталог с уникальным именем атомарно: шесть X в шаблоне заменяются случайными символами, и если такой каталог уже есть, функция пробует снова. Самодельное "/tmp/zbox-" ++ pid было бы предсказуемым именем, а предсказуемое имя в общем /tmp это классическая дыра: кто-то создаёт каталог или ссылку с этим именем раньше тебя. В std.c Zig 0.16 объявления mkdtemp нет, поэтому мы объявляем его сами через extern "c", как execvp в run.zig. deleteTree в defer убирает каталог вместе с кэшем компилятора, что бы ни случилось дальше.
runSource строит две команды args.Command и зовёт run() дважды. Команда компилятора получает cwd, и это единственное новое поле в args.zig: компилятор должен видеть main.zig в текущем каталоге, а свой кэш класть рядом. Флага командной строки у этого поля нет, оно нужно только serve.
writeJson переиспользует report.writeJson и дописывает одно поле. Строка JSON собирается во временный буфер, с конца срезается }\n, и дописывается ,"stage":"run"}. Своего кода тут три строки, а формат ответа у zbox run и zbox serve совпадает по построению: один печатает, другой дописывает поле.
args.zig и run.zig: рабочий каталог
Поле cwd и одна проверка в ребёнке:
/// Программа получает пустое сетевое пространство имён. Только Linux.
no_net: bool = false,
+ /// Рабочий каталог программы: `chdir` в ребёнке перед `execvp`.
+ /// null значит унаследовать. Нужен `zbox serve`, флага у него нет.
+ cwd: ?[]const u8 = null,
/// Программа и её аргументы: то, что уйдёт в `execvp`.
argv: []const []const u8,
const c_argv = try arena.allocSentinel(?[*:0]const u8, argv.len, null);
for (argv, c_argv) |arg, *slot| slot.* = try arena.dupeZ(u8, arg);
+ const cwd_z: ?[*:0]const u8 = if (command.cwd) |cwd| try arena.dupeZ(u8, cwd) else null;
// Группу cgroups тоже готовим до fork: каталог, лимит и путь к
@@
if (!memlimit.limitAddressSpace(as_mb)) childFail(limit_failed_code, "zbox: не удалось поставить лимит памяти\n");
}
+ if (cwd_z) |dir| {
+ if (c.chdir(dir) != 0) childFail(exec_failed_code, "zbox: не удалось перейти в рабочий каталог\n");
+ }
// Мы в ребёнке. Успешный execvp не возвращается: образ процесса
// заменён, этого кода в памяти больше нет.
Строка пути превращается в [*:0]const u8 до fork, по тому же правилу про async-signal-safe, а chdir ребёнок делает сам, после лимитов и перед execvp: рабочий каталог принадлежит процессу и переживает execve, как таблица дескрипторов.
watchdog.zig: SIGPIPE обратно
Теперь та самая строка, ради которой мы разбирали диспозиции. zbox serve это TINY, а Server.init ставит на SIGPIPE SIG_IGN. run() делает fork и execvp программы из запроса, и игнорирование переходит к ней, как к yes в опыте выше. Программа, которая пишет в закрытый пайп, под zbox run тихо умирает от сигнала, а под zbox serve получала бы EPIPE и вела бы себя по-другому. Песочница, в которой код ведёт себя не так, как у ученика на машине, плохая песочница.
Место для исправления уже есть: watchdog.childSetup, где ребёнок возвращает себе маску сигналов перед execvp (урок 49). Добавляем туда одну строку:
// стартует с заблокированными SIGCHLD и SIGALRM.
posix.sigprocmask(posix.SIG.SETMASK, old_mask, null);
+ // Игнорируемый сигнал тоже переживает execve. `zbox serve` игнорирует
+ // SIGPIPE (так делает TINY), а программе нужна обычная смерть от него.
+ posix.sigaction(.PIPE, &.{ .handler = .{ .handler = posix.SIG.DFL }, .mask = posix.sigemptyset(), .flags = 0 }, null);
}
Проверим обе версии программой, которая смотрит на свою диспозицию SIGPIPE и печатает её:
const std = @import("std");
pub fn main() void {
var old: std.posix.Sigaction = undefined;
std.posix.sigaction(.PIPE, null, &old);
const word = if (old.handler.handler == std.posix.SIG.DFL) "SIG_DFL\n" else if (old.handler.handler == std.posix.SIG.IGN) "SIG_IGN\n" else "handler\n";
_ = std.c.write(1, word.ptr, word.len);
}
Без новой строки в childSetup:
$ curl -s --data-binary @pipe.json http://localhost:8082/run
{"exit_code":0,...,"stdout":"SIG_IGN\n","stderr":"","net":"host","stage":"run"}
С ней:
$ curl -s --data-binary @pipe.json http://localhost:8081/run
{"exit_code":0,...,"stdout":"SIG_DFL\n","stderr":"","net":"host","stage":"run"}
Заметь, что программа написана с голым pub fn main() void: с main(init: std.process.Init) она увидела бы пустой обработчик, который поставил её собственный Io.Threaded.init уже после execve, и проверка ничего бы не показала. Строку SIG_DFL тест шага проверяет той же программой.
Почему мы чиним в zbox, а не в TINY? TINY вправе выбирать диспозиции для себя: он сервер. А что достаётся запущенной программе, решает тот, кто её запускает, и childSetup как раз то место, где zbox отмывает ребёнка от всего, что накопил родитель: маску, диспозиции, stdin, дескрипторы.
main.zig и root.zig
Подкоманда serve и обработчик маршрута:
const std = @import("std");
+const tiny = @import("tiny");
const zbox = @import("zbox");
@@
\\Использование:
\\ zbox run [флаги] <программа> [аргументы...]
+ \\ zbox serve <port> HTTP: POST /run с исходником main.zig в JSON, порт 0 это любой свободный
\\
\\Флаги:
@@
const out = &stdout.interface;
+ if (args.len > 1 and std.mem.eql(u8, args[1], "serve")) {
+ const port = zbox.serve.parsePort(args[1..]) catch {
+ try out.writeAll(usage);
+ try out.flush();
+ std.process.exit(2);
+ };
+ return serve(init, port);
+ }
+
const command = zbox.args.parse(args[1..]) catch {
try out.writeAll(usage);
@@
}
+/// `zbox serve`: TINY с одним маршрутом и без статики. Итеративный сервер:
+/// пока идёт сборка и запуск, следующий клиент ждёт в очереди `listen`.
+fn serve(init: std.process.Init, port: u16) !void {
+ server_io = init.io;
+ const routes = [_]tiny.Route{.{ .method = .POST, .path = "/run", .handle = handleRun }};
+ var server: tiny.Server = try .init(init.gpa, init.io, .{
+ .port = port,
+ .routes = &routes,
+ .max_body = zbox.serve.max_body,
+ .serve_files = false,
+ });
+ defer server.deinit();
+
+ // Настоящий порт в stderr: с `serve 0` его иначе не узнать.
+ var err_buf: [256]u8 = undefined;
+ var stderr = std.Io.File.stderr().writerStreaming(init.io, &err_buf);
+ try stderr.interface.print("zbox: listening on port {d}\n", .{server.port});
+ try stderr.interface.flush();
+ try server.serveForever();
+}
+
+/// Обработчик маршрута получает только арену и запрос, а `runSource`
+/// нужен `Io`. Сервер один на процесс, поэтому хватает глобальной переменной.
+var server_io: std.Io = undefined;
+
+fn handleRun(arena: std.mem.Allocator, req: *const tiny.Request) anyerror!tiny.Response {
+ const request = zbox.serve.parseRequest(arena, req.body) catch |err| switch (err) {
+ error.OutOfMemory => return err,
+ else => return .{
+ .status = 400,
+ .content_type = "application/json",
+ .body = try std.fmt.allocPrint(arena, "{{\"error\":\"{t}\"}}\n", .{err}),
+ },
+ };
+ const reply = try zbox.serve.runSource(arena, server_io, request);
+ var body: std.Io.Writer.Allocating = .init(arena);
+ try zbox.serve.writeJson(&body.writer, reply);
+ return .{ .content_type = "application/json", .body = body.written() };
+}
pub const report = @import("box/report.zig");
pub const run = @import("box/run.zig");
+pub const serve = @import("box/serve.zig");
pub const watchdog = @import("box/watchdog.zig");
Обработчик маршрута в tiny это указатель на функцию, *const fn (arena, req) anyerror!Response, без контекста. А runSource нужен Io. Можно было бы добавить в Route поле ctx: *anyopaque и приводить его обратно в обработчике, но сервер в процессе один, и глобальная переменная server_io честно говорит об этом. Цена: второй Server с другим Io в том же процессе сломал бы обработчик. Для zbox serve такого не бывает.
serve_files = false важнее, чем кажется. Без него zbox serve, запущенный из каталога проекта, раздавал бы всем желающим свой build.zig, исходники и всё, что лежит рядом, а GET /cgi-bin/... запускал бы программы. Сервис, у которого одна задача, не должен уметь ничего сверх неё.
handleRun превращает ошибки разбора в 400 с JSON-телом, а всё остальное (не создался каталог, mem_mb на macOS) отдаёт серверу, и тот отвечает 500 и пишет строку в лог.
Прогон
macOS 26. hello.json это программа, которая печатает привет и выходит с кодом 4, sum.json складывает числа из stdin ("stdin":"1\n2\n39\n"), pipe.json это программа про SIGPIPE выше.
$ zbox serve 8081
zbox: listening on port 8081
$ curl -si --data-binary @hello.json http://localhost:8081/run
HTTP/1.0 200 OK
Server: Tiny Web Server
Connection: close
Content-length: 318
Content-type: application/json
{"exit_code":4,"signal":null,"timed_out":false,"cpu_user_ms":5,"cpu_sys_ms":1,"max_rss_kb":2976,"wall_ms":331,"reason":"exited","vm_peak_kb":null,"vm_hwm_kb":null,"cgroup_peak_kb":null,"oom_kills":null,"stdout_truncated":false,"stderr_truncated":false,"stdout":"привет\n","stderr":"","net":"host","stage":"run"}
$ curl -s --data-binary @sum.json http://localhost:8081/run
{"exit_code":0,...,"reason":"exited",...,"stdout":"42\n","stderr":"","net":"host","stage":"run"}
$ curl -s --data-binary '{"source":"pub fn main() void { while (true) {} }","time_ms":300}' http://localhost:8081/run
{"exit_code":null,"signal":9,"timed_out":true,"cpu_user_ms":0,"cpu_sys_ms":0,"max_rss_kb":32,"wall_ms":310,"reason":"time_limit","vm_peak_kb":null,"vm_hwm_kb":null,"cgroup_peak_kb":null,"oom_kills":null,"stdout_truncated":false,"stderr_truncated":false,"stdout":"","stderr":"","net":"host","stage":"run"}
$ curl -s --data-binary '{"source":"pub fn main() void { const x: u8 = 300; _ = x; }"}' http://localhost:8081/run
{"exit_code":1,...,"max_rss_kb":111808,...,"stderr":"main.zig:1:36: error: type 'u8' cannot represent integer value '300'\n...","net":"host","stage":"compile"}
$ curl -si --data-binary '{source: x}' http://localhost:8081/run
HTTP/1.0 400 Bad Request
Server: Tiny Web Server
Connection: close
Content-length: 20
Content-type: application/json
{"error":"BadJson"}
$ curl -s --data-binary '{}' http://localhost:8081/run
{"error":"EmptySource"}
$ curl -si http://localhost:8081/run | head -1
HTTP/1.0 404 Not Found
$ curl -si http://localhost:8081/build.zig | head -1
HTTP/1.0 404 Not Found
В stderr сервера:
127.0.0.1:61727 "POST /run HTTP/1.1" 200 318
127.0.0.1:61729 "POST /run HTTP/1.1" 200 308
127.0.0.1:61731 "POST /run HTTP/1.1" 200 305
127.0.0.1:61734 "POST /run HTTP/1.1" 200 824
127.0.0.1:61736 "POST /run HTTP/1.1" 200 313
127.0.0.1:61738 "POST /run HTTP/1.1" 400 20
127.0.0.1:61740 "POST /run HTTP/1.1" 400 24
127.0.0.1:61742 "GET /run HTTP/1.1" 404 0
127.0.0.1:61744 "GET /build.zig HTTP/1.1" 404 0
Бесконечный цикл убит через 310 мс с причиной time_limit, хотя компиляция перед ним заняла заметно больше: лимит достался только программе. Ошибка компиляции пришла с stage: compile и полным текстом zig, а max_rss_kb в этом ответе, 111 МБ, это память компилятора. ls /tmp | grep zbox- после прогона пуст: каждый каталог удалён. Под Linux с "mem_mb":64 в ответе появляются пики из /proc и счётчики cgroups (vm_peak_kb, cgroup_peak_kb, oom_kills), остальное то же.
Тесты шага
//! Шаг 66: `POST /run` поверх TINY. Разбор запроса, сборка и запуск
//! исходника через `run()`, ответ с полем `stage`, и то же самое через
//! настоящий `zbox serve 0` по HTTP.
const std = @import("std");
const build_options = @import("build_options");
const tiny = @import("tiny");
const zbox = @import("zbox");
const support = @import("support.zig");
const testing = std.testing;
const serve = zbox.serve;
const hello =
\\const std = @import("std");
\\pub fn main(init: std.process.Init) !void {
\\ var buf: [64]u8 = undefined;
\\ var out = std.Io.File.stdout().writerStreaming(init.io, &buf);
\\ try out.interface.writeAll("привет\n");
\\ try out.interface.flush();
\\ std.process.exit(4);
\\}
;
const forever =
\\pub fn main() void {
\\ while (true) {}
\\}
;
// Сервер игнорирует SIGPIPE, программа обязана получить его обратно.
const sigpipe =
\\const std = @import("std");
\\pub fn main() void {
\\ var old: std.posix.Sigaction = undefined;
\\ std.posix.sigaction(.PIPE, null, &old);
\\ std.process.exit(if (old.handler.handler == std.posix.SIG.DFL) 0 else 1);
\\}
;
const broken =
\\pub fn main() void {
\\ const x: u8 = "не число";
\\}
;
const echo_stdin =
\\const std = @import("std");
\\pub fn main(init: std.process.Init) !void {
\\ var in_buf: [64]u8 = undefined;
\\ var reader = std.Io.File.stdin().readerStreaming(init.io, &in_buf);
\\ const line = try reader.interface.takeDelimiterExclusive('\n');
\\ var out_buf: [64]u8 = undefined;
\\ var out = std.Io.File.stdout().writerStreaming(init.io, &out_buf);
\\ try out.interface.print("ты сказал: {s}\n", .{line});
\\ try out.interface.flush();
\\}
;
test "parseRequest: поля и значения по умолчанию" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const full = try serve.parseRequest(arena, "{\"source\":\"pub fn main() void {}\",\"time_ms\":300,\"mem_mb\":64,\"stdin\":\"1 2\\n\"}");
try testing.expectEqual(300, full.time_ms);
try testing.expectEqual(64, full.mem_mb);
try testing.expectEqualStrings("1 2\n", full.stdin);
const bare = try serve.parseRequest(arena, "{\"source\":\"x\",\"extra\":true}");
try testing.expectEqual(2000, bare.time_ms);
try testing.expectEqual(null, bare.mem_mb);
try testing.expectEqualStrings("", bare.stdin);
}
test "parseRequest: кривой JSON, пустой исходник, нулевой лимит" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
try testing.expectError(error.BadJson, serve.parseRequest(arena, "{source: x}"));
try testing.expectError(error.BadJson, serve.parseRequest(arena, ""));
try testing.expectError(error.EmptySource, serve.parseRequest(arena, "{}"));
try testing.expectError(error.BadLimit, serve.parseRequest(arena, "{\"source\":\"x\",\"time_ms\":0}"));
}
test "parsePort" {
try testing.expectEqual(8080, try serve.parsePort(&.{ "serve", "8080" }));
try testing.expectEqual(0, try serve.parsePort(&.{ "serve", "0" }));
try testing.expectError(error.BadUsage, serve.parsePort(&.{"serve"}));
try testing.expectError(error.BadUsage, serve.parsePort(&.{ "serve", "70000" }));
try testing.expectError(error.BadUsage, serve.parsePort(&.{ "run", "80" }));
}
test "writeJson: поле stage в конце того же ответа" {
var buf: [4096]u8 = undefined;
var out: std.Io.Writer = .fixed(&buf);
try serve.writeJson(&out, .{ .stage = .run, .outcome = .{ .exit_code = 4, .stdout = "привет\n" } });
try testing.expect(std.mem.startsWith(u8, out.buffered(), "{\"exit_code\":4,"));
try testing.expect(std.mem.endsWith(u8, out.buffered(), "\"stdout\":\"привет\\n\",\"stderr\":\"\",\"net\":\"host\",\"stage\":\"run\"}\n"));
}
test "runSource: программа собрана, вывод и код возврата на месте" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const reply = try serve.runSource(arena_state.allocator(), testing.io, .{ .source = hello });
try testing.expectEqual(.run, reply.stage);
try testing.expectEqual(4, reply.outcome.exit_code);
try testing.expectEqualStrings("привет\n", reply.outcome.stdout);
}
test "runSource: stdin из запроса" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const reply = try serve.runSource(arena_state.allocator(), testing.io, .{ .source = echo_stdin, .stdin = "ага\n" });
try testing.expectEqual(.run, reply.stage);
try testing.expectEqualStrings("ты сказал: ага\n", reply.outcome.stdout);
}
test "runSource: бесконечный цикл убит по лимиту, а не компилятор" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const reply = try serve.runSource(arena_state.allocator(), testing.io, .{ .source = forever, .time_ms = 300 });
try testing.expectEqual(.run, reply.stage);
try testing.expect(reply.outcome.timed_out);
try testing.expectEqualStrings("time_limit", @tagName(reply.outcome.reason()));
try testing.expect(reply.outcome.wall_ms < 4000);
}
test "runSource: ошибка компиляции это stage compile с текстом компилятора" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const reply = try serve.runSource(arena_state.allocator(), testing.io, .{ .source = broken });
try testing.expectEqual(.compile, reply.stage);
try testing.expect(reply.outcome.exit_code.? != 0);
try testing.expect(std.mem.indexOf(u8, reply.outcome.stderr, "error:") != null);
}
test "runSource: временный каталог убран" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
_ = try serve.runSource(arena_state.allocator(), testing.io, .{ .source = hello });
var tmp = try std.Io.Dir.openDirAbsolute(testing.io, "/tmp", .{ .iterate = true });
defer tmp.close(testing.io);
var entries = tmp.iterate();
while (try entries.next(testing.io)) |entry| {
try testing.expect(!std.mem.startsWith(u8, entry.name, "zbox-"));
}
}
/// Настоящий `zbox serve 0` дочерним процессом. Порт он выбирает сам
/// и печатает первой строкой в stderr.
const Serve = struct {
child: std.process.Child,
port: u16,
fn start() !Serve {
var child = try std.process.spawn(testing.io, .{
.argv = &.{ build_options.zbox_exe, "serve", "0" },
.stdin = .ignore,
.stdout = .ignore,
.stderr = .pipe,
});
errdefer child.kill(testing.io);
var buf: [256]u8 = undefined;
var reader = child.stderr.?.readerStreaming(testing.io, &buf);
const line = try reader.interface.takeDelimiterExclusive('\n');
const prefix = "zbox: listening on port ";
if (!std.mem.startsWith(u8, line, prefix)) return error.NoPort;
return .{ .child = child, .port = try std.fmt.parseInt(u16, line[prefix.len..], 10) };
}
fn stop(s: *Serve) void {
s.child.kill(testing.io);
}
};
const HttpReply = struct { status: u16, body: []const u8 };
/// Сырой запрос по сокету из модуля `tiny`, ответ читается до закрытия.
fn exchange(arena: std.mem.Allocator, port: u16, raw: []const u8) !HttpReply {
const fd = try tiny.socket.openClientfd("127.0.0.1", port);
defer tiny.socket.close(fd);
try tiny.fdio.writen(fd, raw);
var response: std.ArrayList(u8) = .empty;
var chunk: [4096]u8 = undefined;
while (true) {
const n = try tiny.fdio.readn(fd, &chunk);
try response.appendSlice(arena, chunk[0..n]);
if (n < chunk.len) break;
}
const split = std.mem.indexOf(u8, response.items, "\r\n\r\n") orelse return error.NoHeaderEnd;
// "HTTP/1.0 200 OK": код стоит с 9-го по 12-й байт.
if (split < 12) return error.BadStatusLine;
return .{
.status = try std.fmt.parseInt(u16, response.items[9..12], 10),
.body = response.items[split + 4 ..],
};
}
fn postRun(arena: std.mem.Allocator, port: u16, json: []const u8) !HttpReply {
const raw = try std.fmt.allocPrint(arena, "POST /run HTTP/1.0\r\nContent-Type: application/json\r\nContent-Length: {d}\r\n\r\n{s}", .{ json.len, json });
return exchange(arena, port, raw);
}
test "zbox serve: POST /run собирает и запускает, лимит времени работает" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var server = try Serve.start();
defer server.stop();
const ok = try postRun(arena, server.port, try std.json.Stringify.valueAlloc(arena, .{ .source = hello }, .{}));
try testing.expectEqual(200, ok.status);
const run = try support.parseReply(arena, ok.body);
try testing.expectEqual(4, run.reply.exit_code);
try testing.expectEqualStrings("привет\n", run.reply.stdout);
try testing.expect(std.mem.indexOf(u8, ok.body, "\"stage\":\"run\"") != null);
const slow = try postRun(arena, server.port, try std.json.Stringify.valueAlloc(arena, .{ .source = forever, .time_ms = 300 }, .{}));
try testing.expectEqual(200, slow.status);
const killed = try support.parseReply(arena, slow.body);
try testing.expect(killed.reply.timed_out);
try testing.expectEqualStrings("time_limit", killed.reply.reason);
const pipe = try postRun(arena, server.port, try std.json.Stringify.valueAlloc(arena, .{ .source = sigpipe }, .{}));
try testing.expectEqual(0, (try support.parseReply(arena, pipe.body)).reply.exit_code);
}
test "zbox serve: 400 на кривой JSON, 404 мимо маршрута, 413 на большое тело" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var server = try Serve.start();
defer server.stop();
const bad = try postRun(arena, server.port, "{source: x}");
try testing.expectEqual(400, bad.status);
try testing.expectEqualStrings("{\"error\":\"BadJson\"}\n", bad.body);
try testing.expectEqual(400, (try postRun(arena, server.port, "{}")).status);
// Статики у zbox нет: ни GET /run, ни файлы текущего каталога.
try testing.expectEqual(404, (try exchange(arena, server.port, "GET /run HTTP/1.0\r\n\r\n")).status);
try testing.expectEqual(404, (try exchange(arena, server.port, "GET /build.zig HTTP/1.0\r\n\r\n")).status);
const big = try std.fmt.allocPrint(arena, "POST /run HTTP/1.0\r\nContent-Length: {d}\r\n\r\n", .{serve.max_body + 1});
try testing.expectEqual(413, (try exchange(arena, server.port, big)).status);
}
Первая половина проверяет serve.zig без сети: разбор JSON, порт, формат ответа, обе стадии, stdin, лимит времени, удалённый каталог. Вторая запускает настоящий zbox serve 0 дочерним процессом, читает порт из первой строки его stderr и говорит с ним сырым HTTP через сокет из модуля tiny. Заметь, что тесты zbox берут openClientfd и writen прямо из tiny: библиотека годится не только серверу.
$ zig build test --summary all
Build Summary: 17/17 steps succeeded; 61/66 tests passed (5 skipped)
$ zig build test -Dstep=66 --summary all
Build Summary: 7/7 steps succeeded; 11/11 tests passed
На macOS пропущены пять тестов, которым нужен Linux: cgroups из урока 54 и сеть из урока 64. В контейнере --privileged под Linux проходят 65 из 66, пропуск один: тест «не Linux», который проверяет отказ --no-net на других системах.
На macOS
Весь урок работает на macOS напрямую, выводы сняты на macOS 26 (Apple Silicon): TINY, adder, curl, sigpipe_exec.zig, zbox serve и тесты обоих шагов. Код идёт через libc, на macOS она линкуется всегда, на Linux эталоны линкуют её сами (link_libc = true), а для отдельного листинга добавь -lc. Всё то же проверено в контейнере с Debian 12 (linux/arm64): 43 теста TINY и тесты zbox до шага 66 включительно.
Разница в мелочах. yes из GNU coreutils пишет standard output: Broken pipe вместо stdout: Broken pipe. MSG_NOSIGNAL есть только в Linux, в macOS для того же служит опция сокета SO_NOSIGPIPE. Лимит mem_mb в запросе к zbox serve работает только в Linux от root с cgroups v2, на macOS такой запрос получает 500. Порты 8066 и 8081 выбраны выше 1024: ниже на Linux нужны права. Если zbox serve отвечает 500 на всё подряд, проверь, что zig виден в PATH у сервера, или укажи его явно через переменную ZBOX_ZIG.
Изоляции у zbox serve пока нет: программа из запроса видит машину так же, как под zbox run без флагов, со всеми твоими файлами и сетью. Это учебный стенд, а не песочница. Выставлять его наружу нельзя, даже на минуту.
Практика
Задача про чтение заголовков, только строже, чем readRequestHeaders в эталоне. Эталон срезает пробелы вокруг имени заголовка и отдаёт Content-Length прямо в std.fmt.parseInt. Задача требует того, чего требует RFC 9112: пробел или таб в имени (Host : x) и строка, начатая с пробела, это ошибка, Content-Length это только цифры (parseInt сам по себе пропустил бы +5 и 1_0), а два Content-Length с разными значениями это отказ. Последнее не формальность: если прокси перед сервером верит первому заголовку, а сервер второму, они по-разному нарежут поток на запросы, и в чужое соединение можно подложить свой запрос. Такие атаки называются контрабандой запросов. Ещё тесты проверяют, что Host указывает в хранилище заголовков, а не в буфер читателя: буфер перезапишется, пока сервер читает тело.
Упражнения
Итоги
- TINY это итеративный сервер:
acceptв цикле, одна транзакция за раз, остальные клиенты ждут в очередиlisten.closeв конце это конец ответа для клиента HTTP/1.0. doitидёт по порядку: строка запроса, заголовки, тело поContent-Length, маршруты, метод,parseUri, статика или CGI. Каждый отказ по дороге становится своим кодом и страницейclienterror, а отложенныйflushгарантирует, что страница дойдёт.- Статика:
stat, заголовок,mmapфайла и одинwritenиз отображения. Копии в памяти программы нет, короткие счёты сокета дописываетwriten.HEADи пустой файл идут мимоmmap. - CGI: проверить файл и бит исполнения до статус-строки, собрать окружение до
fork, сбросить буфер доfork, в ребёнкеdup2пайпа с телом на 0 и сокета на 1 иexecve, в родителе записать тело, закрыть пайп и дождатьсяwaitpid. Провалexecveвиден только в логе, какexit 127. SIGPIPEпо умолчанию убивает процесс, который пишет в закрытое соединение. Сервер обязан его игнорировать и разбиратьEPIPEкак обычную ошибку записи.SIG_IGNпереживаетexecve, установленный обработчик нет. ПоэтомуIo.Threadedставит пустой обработчик, TINY ставитSIG_IGN, аzboxвозвращает детямSIG_DFLвchildSetup.HEADэто заголовки без тела,POSTэто тело поContent-Length, для CGI через stdin, видео это правильныйContent-type, а перемотка требуетRange.- Пакет Zig подключается к другому по пути:
.dependencies = .{ .tiny = .{ .path = "../our-tiny" } }вbuild.zig.zonиb.dependency("tiny", ...).module("tiny")вbuild.zig. zbox serve: TINY с одним маршрутом иserve_files = false, JSON черезstd.json.parseFromSliceLeaky, временный каталог черезmkdtemp, две стадии через ту жеrun(), ответzbox runплюсstage.- Структуру, на поле которой кто-то держит указатель, нельзя перемещать. Возврат по значению это перемещение.
Дальше
Сегодня TINY стал сервером целиком: файлы через mmap, программы через fork и execve, честные коды на каждый отказ, живучесть при обрыве клиента. А zbox получил HTTP-вход и превратился в маленький раннер: исходник приходит по POST /run, собирается, запускается под лимитами и возвращается JSON-ом. Только пустить в него чужой код пока нельзя: программа видит все процессы, все файлы и всю сеть машины. В следующем уроке мы закроем это так, как это делает раннер курса: пространства имён, своя корневая ФС через pivot_root, пустые capabilities и фильтр seccomp, собранный из байтов. Каждый флаг docker run у bs-runner окажется системным вызовом, который ты написал сам.
домашка