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
# macOS: brew install chezscheme libuv · Debian: apt install chezscheme libuv1-dev zlib1g-dev git clone https://github.com/guenchi/Igropyr igropyr export CHEZSCHEMELIBDIRS=. scheme --script igropyr/test/run-otp.sc # curl localhost:8080/ — and try killing it: curl localhost:8080/crash
Every request runs in a supervised worker pool. Handlers don't defend — they crash, and the system recovers.
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)))))
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.
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")))
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.
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.
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.
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.
"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.
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.
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.
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.
One dependency-free tree of R6RS libraries. No plugins to hunt down — the batteries are in the box.
(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 corespawn / send / receive / link / monitor; no shared state between processeson-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 remainssuspend! answers and parks, conversation-resume! continues, and death for any reason (crash, TTL) means guaranteed rollback: a later resume gets goneres-begin!/res-write!/res-end!; Server-Sent Events helpers on topgen-server (call/cast/info), a process registry (register/whereis), and topic PubSub with automatic cleanup of dead subscribersread; full escape and surrogate handling) and writerreq-form parses urlencoded and multipart bodies (file uploads included); req-cookie / set-cookie!Transfer-Encoding: chunked request bodies are decoded transparentlyhttp-get / http-post and ws-connect, both with async DNS (libuv thread pool) and the same park-the-caller modelAccept-Encoding; static files cache their compressed form/metrics endpointhttp-stats (live connection/request/pool counters), http-shutdown! (drain in-flight requests, refuse new connections)SO_REUSEPORT bind option for kernel-balanced multi-process listening on Linux (pair with pm2 or systemd)Content-Length validation, per-request response isolation, response-header injection guard, static-mount boundary + symlink-escape + NUL-byte checks, pipeline flood cap, WebSocket frame validation with strict UTF-8 (1007 close) and a reassembly cap, binary-safe Redis replies, request-id matching on all DB/HTTP clientsab -n 50000 -c 500, zero failed requests)