A Erlang style pure Scheme Webserver and further
摘要
文章介绍 Igropyr:纯 Chez Scheme + libuv 事件循环 + Erlang-style actors;核心特性包括 1)故障自愈 —— 请求在监督者管理的 worker 池中运行,崩溃 worker 即时替换,重试上限 3 次或 30s 超时自动替换,无执行中执行痕迹;2)热代码替换 —— 运行中替换 handler 或路由,in-flight 请求继续旧代码,结合 graceful shutdown 和 SO REUSEPORT 实现零宕机;3)远程重试环 —— 故障时通过 keep-alive 连接返回结构化 JSON 故障码(crash/stuck),客户端可带参数/状态重试,失败不可见;4)对话即进程 —— 多轮对话(如向导/转账)以 green process 运行,持有 live state(如 open DB transaction),suspend/resume 保证死后 rollback;5)Scheme 间通信 ——sexpr RPC(igropyr sexpr parser)跨 HTTP/WS/SSE,无 codec 浮点问题,精确 ratios/bignums;6)基础组件 ——gen-server、PubSub、cookie session、Redis/MySQL 非阻塞客户端、JSON/sexpr 安全 parser、middleware 套件等。
荐读理由
Igropyr 用 Erlang-style green processes + libuv 事件循环实现热代码交换零重启部署,并让 crashed/stuck 进程自动替换重试, supervisor 管理 worker 池,实现服务器级故障自愈
原文
λ
Igropyr
A web server where crashes heal themselves, code hot-swaps, faults speak a protocol, and dialogues are processes.
Pure Chez Scheme · Erlang-style actors · libuv event loop · MIT
$ npm i igropyr
01 · Fault tolerance
Let It Crash
Every request runs in a supervised worker pool. Handlers don't defend — they crash, and the system recovers.
Crashes heal themselves
A crashed worker is replaced instantly; the task is retried on a fresh worker, up to 3 times, before the client sees an error.
A worker stuck longer than 30 s — even a CPU-spinning loop — is killed and replaced: preemptive scheduling means nothing can freeze the server.
A half-sent request parks only its own reader process and is reaped by timeout. Other connections never notice.
Write the happy path. The supervisor owns the sad one.
(app-get app "/crash"
(lambda (req res)
;; the worker dies; the supervisor retries on a
;; fresh worker -- the pool refills itself
(raise 'handler-crashed)))
(app-get app "/stuck"
(lambda (req res)
;; a hot loop cannot freeze the system: preemption
;; keeps serving, the ticker kills this worker
(let loop ((n 0)) (loop (+ n 1)))))
02 · Live systems
Hot code swapping
Replace the handler — or a single route — on a running server. The listener, open connections and the worker pool stay up; in-flight requests finish on the old code.
Deploy without a restart
Routes live in a mutable registry behind the pool. Re-registering a path replaces it atomically for the next request; http-swap! replaces the whole handler the same way.
Combined with graceful shutdown (http-shutdown! drains in-flight work) and SO_REUSEPORT multi-process listening, zero-downtime operation is the default, not a project.
(app-get app "/version"
(lambda (req res) (send-text! res "v1")))
;; hit /upgrade on the LIVE server:
(app-get app "/upgrade"
(lambda (req res)
(app-get app "/version" ; re-register =
(lambda (req res) ; hot replace
(send-text! res "v2 (hot swapped)")))
(send-text! res "upgraded")))
03 · The remote retry ring
Faults speak a protocol
When retries are exhausted or a stuck worker is killed, Igropyr doesn't just throw a 500 — it can tell the client exactly what happened, on a connection that stays open.
Killed first, told after
The on-failure hook answers a structured fault after the stuck worker is dead — so when the client hears stuck, there is no execution left in flight. The state is definite.
crash — retries exhausted; resubmit with changed parameters, or compensate.
stuck — killed mid-flight; resubmit carrying state, or roll back.
Keep-alive survives the fault, so the client resubmits on the same connection and gets a fresh retry round. Shorten stuck-ms and a user who once stared at a spinner for 30 s now rings through several informed retries in the same time — failures become invisible at the UI.
(app-listen app 8080
`((stuck-ms . 3000) ; fail fast
(check-ms . 1000)
(on-failure . ,(make-fault-handler))))
;; the client receives, connection kept alive:
;; {"fault":"crash","attempts":4,"retryable":true}
;; {"fault":"stuck","elapsed-ms":3012,...}
;; unset? the plain 500 remains. zero breakage.
04 · Web programming with continuations
Dialogues are processes
A multi-request dialogue — a wizard, a booking, a transfer — runs as one green process. Its local bindings are the conversation state, including things a session store can never hold: an open database transaction, spanning rounds.
Control flow is program text
"The user is at the confirm step" means the process is parked at that line. A step order the code cannot express cannot happen — no state machine to get wrong, no replay to defend against.
The gone guarantee: death for any reason — crash, TTL, completion — unregisters the process, and a later resume answers gone. Dead process = dropped connection = the database itself rolled back: gone proves nothing committed. Together with the fault codes above, the client always knows the definite server state — a complete remote transaction ring.
(conversation-start!
(lambda (req suspend!)
(let ((tx (begin-tx!))) ; live, across requests
(guard (e (#t (rollback! tx) (raise e)))
(let ((req2 (suspend! confirm-page)))
(commit! tx)
done))))
req)
(conversation-resume! id req) ; => reply | 'gone
;; 'gone means: rolled back. guaranteed.
05 · Scheme talks to Scheme
Communicate in S-expressions
When the client is Scheme too, requests and replies are s-expressions — there is no codec to design. (igropyr sexpr) is the safe parser; app-rpc dispatches one datum per message, over HTTP, WebSocket or SSE.
No codec on the wire
Exact ratios and bignums cross the wire intact — no floating-point JSON approximation anywhere. (rpc "/rpc" '(add 1 2 1/2)) comes back (ok 7/2), the ratio preserved.
The peer is Goeteia, a Scheme compiler that runs in WebAssembly; its (web rpc) / (web ws) / (web sse) speak the same wire format.
This site itself is written in pure Scheme, and compiled to HTML and CSS — the honeycomb fire above is compiled in real time by Goeteia.
;; Igropyr: one s-expression per message
(app-rpc app "/rpc"
`((add . ,(lambda (args) (apply + args)))
(get-user . ,(lambda (args) (find-user (car args))))))
;; a Scheme browser -- Goeteia -- calls it. no JSON, no codec:
(rpc "/rpc" '(add 1 2 1/2))
;; the Igropyr server returns
(ok 7/2) ;; -- exact ratio intact
Foundations
What it stands on
λ
Pure Chez Scheme
Every line is Scheme — R6RS libraries in .sc, no C shim. libuv, zlib and the crypto for MySQL auth are reached through Chez's FFI directly. Whole-program compilation folds the framework and your app into one optimized binary.
✉
Erlang-style actors
Green processes with spawn / send / receive, link and monitor, a process registry, gen-server and PubSub. One OS thread, preemptive scheduling, pure message passing — no shared state, no locks.
⚡
Async on libuv
One event loop feeds thousands of parked processes. DNS, file reads and database round-trips park the calling process, never the thread. Non-blocking HTTP/WebSocket clients and Redis/MySQL drivers included.
120k+
req/s, keep-alive, laptop
0
failed requests under ab -c 500
≤35s
full recovery from a stuck pool
OS thread
Everything included
Core / framework split, like Node and Expressthe core exposes one entry point, (http-listen port (lambda (req res) ...)); the bundled (igropyr express) layer (create-app, app-get, send-json!, ...) is optional, and alternative frameworks can be built on the same core
Green processesthousands of lightweight processes scheduled over one OS thread; continuation-based context switching with preemption, so even a CPU-spinning handler cannot freeze the system
Pure message passingspawn / send / receive / link / monitor; no shared state between processes
Fault tolerant by defaulta fixed worker pool behind a supervisor: crashed workers are replaced and the task retried (at most 3 times, then the client gets a 500); workers stuck for more than 30 s are killed and replaced; a slow or half-sent request only ever blocks its own reader process
**Failure hook (remote retry ring)**when retries are exhausted or a stuck worker is killed (killed first, so no execution is in flight), an optional on-failure handler answers a structured JSON fault instead of the plain 500, on the same keep-alive connection — the client resubmits (changed parameters, carried state) and gets a fresh retry round; unset, the plain 500 remains
**Conversations (process-per-dialogue)**a multi-request dialogue runs as one green process holding live state — even an open database transaction — across rounds; suspend! answers and parks, conversation-resume! continues, and death for any reason (crash, TTL) means guaranteed rollback: a later resume gets gone
Hot code swappingreplace the handler (or individual routes) on a live server: the listener, open connections and worker pool stay up, in-flight requests finish on the old code
WebSocketRFC 6455 upgrade on the same port; each socket is its own green process, so server push is just a message send
Streaming responses & SSEchunked response body via res-begin!/res-write!/res-end!; Server-Sent Events helpers on top
OTP building blocksgen-server (call/cast/info), a process registry (register/whereis), and topic PubSub with automatic cleanup of dead subscribers
JSONa safe recursive-descent parser (no read; full escape and surrogate handling) and writer
S-expression RPCwhen the peer is also Scheme there is no codec: (igropyr sexpr) is a safe whitelisted parser (no read, depth-limited), and app-rpc / send-sexpr! / ws-send-sexpr! / sse-send-sexpr! carry one datum per message — exact ratios and bignums cross intact. The browser end is Goeteia's (web rpc/ws/sse)
Forms & cookiesreq-form parses urlencoded and multipart bodies (file uploads included); req-cookie / set-cookie!
Middleware suitecookie sessions (gen-server store, CSPRNG sids), CORS with preflight, security headers, and an access logger
Chunked transfer-encodingTransfer-Encoding: chunked request bodies are decoded transparently
Non-blocking Redis and MySQL clientspure Scheme, same event loop; callers park their green process while the OS thread keeps serving; MySQL comes with a self-healing connection pool
Non-blocking HTTP & WebSocket clientsoutbound http-get / http-post and ws-connect, both with async DNS (libuv thread pool) and the same park-the-caller model
Async file readsstatic files are read on libuv's thread pool, so a large or cold read never blocks the scheduler
gzip compressionresponses negotiated via Accept-Encoding; static files cache their compressed form
Ops-readyrate limiting, a global error handler, and a Prometheus /metrics endpoint
Runtime introspection & graceful shutdownhttp-stats (live connection/request/pool counters), http-shutdown! (drain in-flight requests, refuse new connections)
Multi-process scalingSO_REUSEPORT bind option for kernel-balanced multi-process listening on Linux (pair with pm2 or systemd)
HTTP/1.1 keep-alive & pipeliningpersistent connections by default on 1.1; each connection's reader process loops over successive requests
Fast~35 k req/s at 500 concurrent connections on an Apple Silicon laptop (ab -n 50000 -c 500, zero failed requests)
Acknowledgements
Built on the shoulders of others
Igropyr is built on Chez Scheme — the fastest Scheme compiler, with a first-class FFI that reaches libuv directly. With deep gratitude for Kent Dybvig's life work, and to Cisco for open-sourcing it.
The primary inspirations: Node.js is the event-loop server on libuv, and the lean core / optional-framework split that Node and Express made the norm. The actor model, the supervisor, and Let It Crash come from Erlang/OTP; Swish — a Chez Scheme system built on those ideas — was the concrete blueprint for the scheduler, the receive macro, and the supervisor. The conversation model is the actor-native take on web programming with continuations — a great idea from the Scheme and functional-programming community.
这条对你有帮助吗?