Загартовуємо 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, дозволяє йому сканувати тільки нові байти, відстежуючи зсув початку пошуку, що робить читання лінійним в одному буфері.

← Усі дописи