Design
Goal
A full web stack where every byte and every cycle has a reason. Measured with make bench (bench/results, i7-10700K, Linux 7.2): examples/web.lpp is a 27 KB binary; one worker uses 52 KB PSS, and four workers use 88 KB PSS in total after serving about 46k requests/s. The compiler peaks at 3–4 MB.
Pipeline
.lpp source -> lex.rs -> ast.rs (parser) -> gen.rs (types, layout, check + x86-64 codegen) -> driver.rs (modules, reachability, ELF64)
The compiler encodes machine instructions itself and writes a single PT_LOAD segment (read + execute; nothing in it is ever written). Strings live right after the code. There are no sections, no symbols, no relocations and no dynamic loader.
Why it is memory safe
- No pointers. Slices are a pointer plus a length the program cannot change.
&Treferences exist only as parameters: they can't be stored, and slices can't be written through them. - Every access is checked.
s[i]ands[a..b]compare against the length with unsigned comparisons, so negative values trap too. - Nothing outlives its frame. A slice can point at a string literal or argument (static), at memory owned by a caller (through a parameter), or at an array in the current frame. Functions return only
int, there are no globals or heap, references can't be stored, and slices can't be stored through a reference. So a slice can only ever move to the same frame or to callees, which end first. - No uninitialised memory. Arrays and variables start at zero.
- One trusted module.
syscallandaddrexist only instd/sys.lpp, and every wrapper passes the slice's own length to the kernel. Each wrapper states its contract at the top of the file. - Every function is checked, including ones that are never called, so an unused function cannot hide an unsafe or ill-typed body.
- The stack is grown page by page. Each prologue touches every new page, so a big frame or deep recursion ends in a clean SIGSEGV at the guard page, never in a write past it.
Calling convention
The caller pushes arguments left to right (a slice is pushed as two words), the callee uses rbp frames, results come back in rax, and the caller pops the arguments. It's simple to debug with objdump -D -b binary -mi386:x86-64.
Trade-offs in 0.1
- Codegen is stack-based with no register allocation. It's fast enough for I/O-bound servers, and the optimizer is on the roadmap.
- Each worker serves one connection at a time. Concurrency comes from forked workers (1–64) sharing one listening socket.
HTTP server
http_serve(port, workers, handler)orhttp_server(&s, ...)+http_run(&s). The handler is a function valuefn(int, &Request) -> int.
- Reads the request head into an 8 KiB buffer across as many reads as needed, within
head_timeout_ms(default 10 s). Too big gives 431, too slow gives 408, malformed gives 400. - Header lines are validated before the handler runs: token names, no control bytes, exactly one Host for HTTP/1.1. Request bodies are not supported (
Content-Length > 0gives 413,Transfer-Encodinggives 501). - Every blocking read and send is bounded by the time left before its deadline (the socket timeout is set to the remaining time before each call), so no single call can overrun it. A response must be sent within 30 s.
- 204 and 304 responses carry no body or Content-Length; a handler that passes a body gets a 500.
- HEAD gets the same headers as GET and no body.
- Static files are opened with
openat2(RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS)relative to the site root, so the kernel enforces confinement. Only regular files are served, streamed in 16 KiB chunks. - After a response the server stops writing and drains what the client still sends (lingering close: at most 64 KiB and
linger_ms, default 1 s, in total), so error responses aren't lost to a connection reset. - The 16 KiB file chunk lives in its own function's frame, so a HEAD request never touches those stack pages.
- Workers die with the parent (
PR_SET_PDEATHSIG), and exited workers are reaped by the kernel. Accept errors back off for 50 ms instead of spinning.
The lpp runner
lpp file.lpp caches binaries in a per-user directory ($XDG_CACHE_HOME/lpp, mode 0700, owner checked). A cached binary is run only if it is a regular file you own with exactly the expected bytes. All outputs are written to an exclusively created temporary file, given an explicit mode (so the umask can't strip execute permission), and renamed into place. lpp build refuses an output path that is one of the program's source files.
The compiler runs on a thread with a 64 MiB stack, on top of the depth limits. LPP_STATS=1 prints its own peak RSS (VmHWM, which resets on exec), which make bench uses.