Загартовуємо HTTP-стек risp: TLS за прапорцем, try/catch як вбудована функція на 30 рядків
Як risp — Lisp на Rust без залежностей — отримав HTTP: TLS за прапорцем Cargo, try/catch вбудована на 30 рядків і квадратичний цикл читання замінено лінійним.
Джерело: github.com/kissishka/risp
risp, про який ідеться в цьому дописі, — це мій власний інтерпретатор Lisp на Rust,
написаний з нуля — Lisp лише на std, який я зібрав із Claude Code,
а не іграшковий калькулятор 2019 року з тією самою назвою, що ширяє туторіальними
блогами. Цей risp має байткод-VM, обчислювач CEK із явним стеком, який ніколи не переповнюється,
і — те, про що тут ідеться, — HTTP/1.1-клієнт і сервер, написані на Lisp поверх
жменьки вбудованих функцій для сокетів.
Планка цього проходу була конкретною: перенести цей HTTP-шар із «працює проти
дружнього localhost» у «спілкується зі справжнім, ворожим, лише-HTTPS вебом», не
здаючи єдиної властивості, яка робить проєкт цікавим, — типової cargo build, що
резолвить нуль зовнішніх крейтів. Кожне рішення нижче випливає з цього напруження.
TLS — це крейт, а статут каже «жодних крейтів»
Продакшен-HTTP означає HTTPS, а HTTPS означає TLS — а TLS немає в стандартній
бібліотеці Rust. Немає жодного std::tls. Тієї миті, коли ви захочете провести
рукостискання з example.com:443, вам знадобиться крипто-крейт, що лоб у лоб
стикається зі статутом «без залежностей». Наївні розв’язки обидва хибні: зробити
rustls завжди-увімкненою залежністю прямо порушує «нуль залежностей», а лишитися
назавжди на відкритому тексті означає, що інтерпретатор ніколи не дотягнеться до
сучасного вебу взагалі.
Розв’язок уже був у дереві. JIT на Cranelift, що обганяє CPython у
10–42 рази — контекст про те, чому CPython усе ще виграє на холодних навантаженнях, — схований за
--features jit, тож типова збірка не тягне Cranelift. TLS отримує таке саме
поводження: rustls живе за --features tls. Типова cargo build працює лише на std
і лише з відкритим текстом; cargo build --features tls вмикає HTTPS. Це не вигинання
статуту — це його збереження, за прецедентом. Прапорець функції — єдиний варіант,
що утримує обидві властивості водночас: типовість без залежностей і шлях до
HTTPS-вебу.
Цей вибір розходиться хвилею аж до одного типу дескриптора сокета. StreamOwned
від rustls потребує &mut self для введення-виведення, тоді як простий TcpStream
задоволений і через спільне &. Щоб нести обидва за одним уніфікованим Value::Tcp,
дескриптор переїхав із Rc<TcpKind> на Rc<RefCell<TcpKind>>, а варіант TLS
огороджений через #[cfg], тож збірка без tls буквально не містить його — зіставлення
лишаються вичерпними без мертвого рукава Tls:
pub enum TcpKind {
Stream(std::net::TcpStream),
Listener(std::net::TcpListener),
#[cfg(feature = "tls")]
Tls(Box<rustls::StreamOwned<rustls::ClientConnection, std::net::TcpStream>>),
}
Варіант запакований у Box, тож він не роздуває Value для випадку, який ніхто на
типовій збірці ніколи не конструює.
Чому ring, а не aws-lc-rs
Сама залежність — це rustls 0.23, що підтягується з default-features = false і
набором функцій ["ring", "std", "tls12", "logging"]. Вимкнення типових налаштувань —
і є вся суть: rustls 0.23 за замовчуванням використовує крипто-провайдера aws-lc-rs,
що тягне за собою інструментарій збірки на C/cmake. Для проєкту, чия вся ідентичність —
це «клонуєш і cargo build без нічого більше встановленого», вимога робочого
C-компілятора і cmake на шляху --features tls була б тихою зрадою того самого духу,
який цей прапорець існує, щоб захищати. Провайдер ring портативний і легкий щодо
інструментарію — без cmake — тож cargo build --features tls лишається простою
збіркою на Rust.
Корені довіри — інше місце, куди TLS-стек зазвичай тягнеться в хост: більшість
клієнтів завантажують сертифікатне сховище ОС. Я натомість використав webpki-roots
0.26, що вшиває набір кореневих сертифікатів Mozilla прямо в бінарник. Жодного
rustls-native-certs, жодної залежності від того, яке сертифікатне сховище випадково
має машина для розгортання. Клієнт довіряє тим самим кореням усюди, де він працює.
Була одна передбачена небезпека — API будівника rustls смикало між версіями — і вона
вкусила саме там, де очікувалося. Форма, яка справді компілюється проти 0.23:
builder_with_provider(...) повертає будівника напряму (без ?), лише
with_safe_default_protocol_versions() повертає Result, і виклик
CryptoProvider::install_default() не потрібен. Старіші сніпети по всьому вебу
помиляються в цьому.
tls-connect існує навіть тоді, коли TLS немає
Ось маленьке рішення з непропорційно великою віддачею: tls-connect зареєстрована як
вбудована функція навіть тоді, коли функцію tls вимкнено. У такій збірці вона не
з’єднується — вона повертає чисту, перехоплювану помилку часу виконання:
tls-connect: TLS not enabled — rebuild with --features tls
Навіщо завдавати собі клопоту з реєстрацією функції, яка не може працювати? Бо
HTTP-бібліотека маршрутизує кожен https://-URL до tls-connect. Якби символу просто
не існувало на типовій збірці, https://-запит злетів би з помилкою незв’язаного
символу — заплутаною аварією, що вказує на нутрощі risp, а не на виправлення.
Зареєстрована, але вимкнена, вона падає з повідомленням, яке точно каже вам, що робити.
Маршрутизацію видно в http--open, який не знає й не дбає, чи скомпільовано TLS;
він просто диспетчеризує за схемою і дає помилці поширитися як значення:
(def http--open
(lambda (parsed timeout)
(let ((scheme (http--get parsed 'scheme "http"))
(host (http--get parsed 'host ""))
(port (http--get parsed 'port 80)))
(let ((sock (if (equal? scheme "https")
(tls-connect host port)
(tcp-connect host port))))
(tcp-set-timeout sock timeout)
sock))))
try/catch як вбудована функція, а не спеціальна форма
risp не мав жодної обробки помилок взагалі: піднята помилка переривала всю програму.
Продакшен-HTTP не може такого допустити — єдиний мережевий збій посеред запиту вбив би
хост. Підручниковий хід — нова спеціальна форма, try/catch. Але спеціальна форма
означає хірургію на обчислювачі CEK із явним стеком:
навчити машину продовжень, як розкручувати часткове обчислення, зберігати її
інваріанти, і — оскільки рушій має два бекенди — робити все це двічі, один раз для
деревообхідного інтерпретатора і один раз для байткод-VM. Справжня робота, справжній
радіус ураження.
Виявляється, нічого з цього не потрібно. Перехоплення помилок випадає зі звичайної
вбудованої функції, яка повторно входить в обчислювач і оглядає Result:
// (error-guard thunk handler): run (thunk); on a raised error, call (handler msg).
match eval::apply(&thunk, &[]) {
Ok(v) => Ok(v),
Err(e) => eval::apply(&handler, &[Value::Str(e.to_string().into())]),
}
Це ядро функції приблизно на 30 рядків. Вона змінює нуль коду обчислювача — жодного
нового ключового слова, жодної хірургії над продовженнями, жодних інваріантів CEK для
збереження — і оскільки вбудовані функції спільні для рушіїв, і деревообхідний
інтерпретатор, і VM безкоштовно отримують відновлювані помилки з одного визначення. Це
той самий інстинкт, що поклав увесь HTTP-протокол у http.lisp, а не в Rust: додай
найменший примітив, а потім вирази все інше поверх нього. Увесь HTTP-шар тепер
обгортає ризиковані операції в error-guard, щоб перетворити підняті мережеві збої на
звичайні значення — результуючий alist замість перерваного процесу — без того, щоб мова
коли-небудь обростала винятками.
Шлях прийому з’єднань на сервері сильно спирається на це. Обробка кожного з’єднання обгорнута так, що покручений клієнт дає відповідь-помилку за принципом «найкращих зусиль» замість того, щоб викидати з циклу, з вкладеним захистом навіть навколо запасного варіанту 500:
(def http--serve-connection
(lambda (sock handler)
(error-guard
(lambda ()
(tcp-set-timeout sock http--server-timeout)
;; ... parse request, call handler, write response, close ...
)
;; On ANY failure (parse/handler/socket), try a 500 then swallow.
(lambda (m)
(error-guard (lambda () (http--send-error sock 500))
(lambda (m2) ()))))))
Цикл читання O(n²), виправлений у Rust
HTTP-читач у risp v1 мав квадратичну ваду, що ховалася на видноті. Щоб знайти \r\n\r\n,
яким закінчується блок заголовків, він читав по 4 КБ за раз і на кожному фрагменті
повторно конкатенував увесь накопичений буфер і пересканував його з початку. Для
великої відповіді це O(n²): кожен крок зростання n-байтного буфера перекопіює все
прочитане досі.
Виправлення полягало в тому, щоб перенести цикл «читати до роздільника» в примітив на
Rust, tcp-read-until, який сканує лише щойно прибулу область на кожній ітерації — та сама дисципліна одного проходу без зайвих алокацій, що розглядається у злитті map, filter і fold.
Він відстежує зсув початку пошуку і, що критично, відсуває цей зсув назад на
delim.len()-1 перед кожним новим читанням, тож роздільник, що осідлав межу читання,
усе одно знаходиться. Уся ця річ накопичується в один Vec<u8> — лінійно, без
повторної конкатенації. Її напарник tcp-read-exact заповнює до точної кількості байтів
для тіла, знову ж таки без пересканування. З Lisp читання заголовка тепер — це один
виклик, який вбудована функція тримає лінійним, із вшитим обмеженням на завеликий розмір:
;; Read the head up to the blank line; oversize -> the builtin raises.
(let ((raw (tcp-read-until sock "\r\n\r\n" max-header)))
(let ((sep (bytes-index raw "\r\n\r\n")))
;; ...
))
Це з’єднується з рішенням про представлення, яке я навмисно не переглядав. Байти в
risp лишаються незмінним Rc<[u8]>, а не змінюваним нарощуваним буфером. Єдина причина,
заради якої ви потягнулися б до змінюваності, — це саме це накопичення O(n) — але
запровадження змінюваного об’єкта в купі зробило б байти єдиним змінюваним значенням в
інакше повністю незмінній системі значень, ламаючи узгодженість мови. Продуктивність, що
заради неї ви хотіли б змінюваності, натомість було відновлено всередині примітивів на
Rust: tcp-read-until і tcp-read-exact роблять своє накопичення в одному приватному
Vec<u8> і повертають незмінний результат. Незмінність нічого не коштує в пропускній
здатності й тримає мову чесною.
Тайм-аути закривають продакшен-прогалину №1
Найбільша окрема прогалина між «працює» і «продакшен» була не TLS — це були тайм-аути.
Блокувальний connect до IP-чорної-діри висить хвилинами; читання проти повільного або
зловмисного сервера, що надсилає один байт на годину, заклинює однопотоковий
інтерпретатор назавжди. Тож tcp-connect тепер резолвить хост через ToSocketAddrs і
набирає з connect_timeout(30s) по розрезолвлених адресах, а tcp-set-timeout встановлює
дедлайни читання і запису (де 0 означає «жодного»), тож жодна з половин загальмованого
обміну не може прип’яти процес. Клієнт протягує типове значення в 30 секунд через кожен
запит; сервер застосовує жорсткіший 10-секундний тайм-аут читання на з’єднання як захист
від slowloris:
;; Default option values, overridable via the per-call opts alist.
(def http--default-timeout 30000)
(def http--default-max-redirects 5)
(def http--default-max-body 33554432) ; 32 MiB
(def http--default-max-header 65536) ; 64 KiB
Хочу бути чесним щодо стелі, під якою сидять ці тайм-аути: risp однопотоковий, тож
сервер обробляє рівно одне з’єднання за раз. Це жорстке обмеження інтерпретатора, а не те,
що цей прохід вдає, ніби виправляє, — клієнт є реалістичним продакшен-артефактом, а сервер
«справжній, але послідовний». Keep-alive так само є навмисною не-метою тут; кожен запит і
відповідь несуть Connection: close, що коректно, просто не оптимально. Ці два «ні» в
продакшен-матриці — проєктні рішення, записані, а не сховані.
Перевірено зеленим, обидві конфігурації
Прохід завершено, і його було незалежно перевірено 2026-06-18, на обох конфігураціях збірки:
- Типовість без залежностей збережена.
cargo treeна типовій збірці показуєrisp v0.1.0, що стоїть на самоті — жодних зовнішніх крейтів — аcargo build,--features tlsі--features jitусі завершуються. Інструментарій aws-lc-rs/cmake так і не прокрався; провайдер ring зберіг TLS-збірку портативною. - Тести зелені. На типовій збірці: lib 48, інтеграційні 75 (зросло з
попередніх 61, тобто +14 продакшен-тестів, що покривають редиректи, обмеження
розмірів, chunked-декодування, POST-echo і серверні обробники), vm 14, doctest 1 —
нуль провалів. Під
--features tls: lib 49 (герметичний тестtls_read_write_dispatch_hermeticпроходить, проштовхуючи байти крізь справжні вбудовані функціїtcp-write/tcp-readчерез варіант Tls, без мережі), інтеграційні 75, vm 14 — нуль провалів. - Справжній HTTPS працює. Жива перевірка
(http-status (http-get "https://example.com/"))повертає 200 під--features tls, виконуючи справжнє рукостискання протиexample.com:443через провайдер ring і вшиті корені webpki. - clippy чистий на обох конфігураціях — нуль попереджень на типовій і на
--features tls.
Часті запитання
- Як додати HTTPS до інтерпретатора без залежностей, не порушуючи його статут?
- Типова cargo build усе ще резолвить нуль зовнішніх крейтів — rustls і webpki-roots з'являються лише з --features tls, дзеркалячи те, як JIT на Cranelift схований за --features jit. Прапорець функції — єдиний варіант, що утримує обидві властивості водночас: типовість без залежностей і шлях до HTTPS-вебу.
- Як додати try/catch, не переписуючи обчислювач із явним стеком?
- Зробити це вбудованою функцією, а не спеціальною формою. Захист від помилок у risp — це функція приблизно на 30 рядків, яка повторно входить в обчислювач на thunk і, у разі піднятої помилки, викликає обробник із рядком повідомлення. Вона не торкається жодного інваріанта машини продовжень і працює і в деревообхідному інтерпретаторі, і в байткод-VM безкоштовно, бо вбудовані функції спільні для всіх рушіїв.
- Чому виправлення HTTP-читача перетворило цикл O(n²) на O(n)?
- Цикл читання в risp v1 повторно конкатенував увесь накопичений буфер на кожному 4-КБ-фрагменті лише для того, щоб просканувати роздільник, що є квадратичним для великих тіл. Перенесення циклу «читати до роздільника» у примітив на Rust, tcp-read-until, дозволяє йому сканувати тільки нові байти, відстежуючи зсув початку пошуку, що робить читання лінійним в одному буфері.