lobo Public
trunk
Code
Use Git or checkout with SVN using the web URL.
No matching headings.
lobo
A web server written in wolf. It reads nginx configuration files and works with the certificate tooling you already have.
brew install wolffe-lang/wolf/lobo # macOS arm64
yay -S lobo-bin # Arch x86-64
To serve the stock page:
mkdir -p ~/lobo && cd ~/lobo
cp -R "$(brew --prefix lobo)"/share/lobo/{conf,html} . # or /usr/share/lobo
mkdir -p logs
lobo -c conf/lobo.conf serve # serves html/ on 127.0.0.1:8080
lobo -s stop -c conf/lobo.conf
lobo -v prints the version and the compiler that built it:
lobo version: lobo/0.1.5+dev (built with wolf 0.2.35, pin cf0c13d)
Version 0.1.5. Static serving, reverse proxy, TLS, ACME, prefork workers, reload with connection draining, structured logs and a control socket all work and are tested. It has not carried anyone's production traffic. Please file what breaks.
Upgrade from 0.1.4: as root with no
userline, the workers are no longer root. On linux they run asnobody, nginx's default, and the master keeps root, so a file only root may read now answers 403 (macOS keeps root and says so at start);user root;keeps 0.1.4's posture. A proxied request now runs beside the serving loop, so a silent upstream no longer holds the process.CHANGELOG.mdhas the upgrade steps.Upgrade from 0.1.3 or earlier. Two faults a client or operator could trip are fixed in 0.1.4, both measured on the released binaries: a process at its descriptor limit could be ended by a client (0.1.2 and 0.1.3), and a master with
worker_processesabove 1 and nocontroldirective spun a cpu and ignored HUP and TERM (0.1.1 to 0.1.3). 0.1.4 also appliesworker_connectionsfor the first time — a site that served more than about 510 connections a process must set it (the stock config's 1024 gives about 1,022).CHANGELOG.mdhas the upgrade steps and the affected versions.
Why another web server
nginx is excellent and twenty years old. Some of its sharpest edges come from the language it is written in, and some from decisions that hardened before anyone knew better. lobo keeps the configuration format and the operational habits that made nginx worth learning, and changes the parts underneath.
Memory safety is a property of the language. wolf checks arithmetic in every build profile, ties request memory to regions, and has no undefined behaviour. The historical nginx CVE inputs are in this repository's test corpus, and CI requires each one to be refused or to trap cleanly.
Configuration mistakes are reported when the file loads. The if
directive, directive inheritance and alias traversal all carry over
from nginx, and the linter points out the places where nginx would have
failed quietly.
The compatibility claim is tested. CI runs a pinned copy of nginx 1.30.4 next to lobo with the same configuration files and compares the responses.
What lobo adds
- ACME is built in, so certificates can be issued and renewed without certbot. certbot still works if you prefer it.
lobo -t --request 'GET https://host/path'dry-runs a request through the routing rules and reports what would happen.- Reloads expose their connection draining, so you can watch a reload finish instead of guessing.
-Treports which file set every directive.- Per-vhost memory budgets are enforced.
- wolf's scheduler is deterministic under test, so a race reported from the field can be replayed.
Performance
One process went from 37 to 13,508 req/s when the serving loop switched from time-slicing to blocking on readiness. Prefork workers came after: the master binds the listeners and passes them to each worker, every worker accepts on the same socket, and the kernel distributes the connections.
lobo is within ten percent of nginx on macOS (measured 2026-10-02)
and on linux (2026-10-11, four runner VMs): both shapes are inside
the line there, and on keepalive lobo answers more requests than
nginx does.
docs/PARITY.md defines the comparison (it was
written before the first measurement) as the ratio nginx ÷ lobo on one
machine, with workers equal to cpus and 32 concurrent clients, over
five interleaved runs; 1.10 or under is parity. The current ledger
(nginx 1.30.4; the macOS row re-taken with wolf 0.2.20, the first five
linux rows with wolf 0.2.27, the four below them with 0.2.26, the two
below those with 0.2.25):
| host | measured | connection-per-request | keepalive |
|---|---|---|---|
| macOS arm64, 18 cpus (load 2.80, other work on the box) | 2026-10-02 | 1.031x | 1.037x |
| linux x86-64, 4 cpus (GitHub Actions runner, load 1.78; 0.1.3's tree, 0.1.2's beside it read 1.132x and 1.161x) | 2026-10-11 | 1.020x | 0.921x |
| linux x86-64, 4 cpus (GitHub Actions runner, load 1.83) | 2026-10-11 | 1.022x | 0.918x |
| linux x86-64, 4 cpus (GitHub Actions runner, load 1.77) | 2026-10-11 | 1.031x | 0.934x |
| linux x86-64, 4 cpus (GitHub Actions runner, load 1.64) | 2026-10-11 | 1.038x | 0.927x |
| linux x86-64, 4 cpus (GitHub Actions runner, load 1.58) | 2026-10-11 | 1.027x | 0.914x |
| linux x86-64, 4 cpus (GitHub Actions runner, load 1.82, a fast VM) | 2026-10-10 | 1.104x | 1.130x |
| linux x86-64, 4 cpus (GitHub Actions runner, load 1.91) | 2026-10-10 | 1.062x | 1.082x |
| linux x86-64, 4 cpus (GitHub Actions runner, load 1.68, a slow VM) | 2026-10-10 | 1.075x | 1.111x |
| linux x86-64, 4 cpus (GitHub Actions runner, load 1.72, a slow VM) | 2026-10-09 | 1.153x | 1.254x |
| linux x86-64, 4 cpus (GitHub Actions runner, load 1.85, a slow VM) | 2026-10-09 | 1.140x | 1.260x |
| linux x86-64, 4 cpus (GitHub Actions runner, load 1.87, a fast VM) | 2026-10-08 | 1.166x | 1.185x |
| linux x86-64, 16 cpus (kasumi, load 0.34–1.89, shared) | 2026-10-02 | no valid set: ~1.84x | no valid set: ~2.04x |
The first runner row is the 0.1.3 release tree with 0.1.2's tree built beside it on the same VM: 0.1.3 answered 1.099x the requests 0.1.2 did with a connection per request and 1.261x on keepalive.
The four runner rows under it are ws58, on four VMs, with wolf 0.2.27: a
small static file's bytes are kept and proved by one stat a request
(the file is seen changed, replaced, deleted or made unreadable on
the next request), where lobo and nginx each made four file calls.
Both shapes are inside ten percent on all four, and on keepalive
nginx is the slower of the two; two workers read 0.96x and
0.82x–0.84x, one worker 0.70x–0.72x and 0.81x–0.89x. The pin alone,
before the memo, read 1.06x–1.08x and 1.09x–1.11x on the same VMs.
The three runner rows under them are ws57, on three VMs: a serving lobo
no longer keeps the memory a request or a connection used (it kept
2 KB and 10 KB, and the kernel's page faults for them were most of
the gap), measured 5 to 10 % faster than the tree before it on each
VM. The connection-per-request shape is inside ten percent on two of
the three and keepalive on one. The three
runner rows under them are three more VMs (the first is ws55's pin at wolf
0.2.26, which measured 1.001x / 0.999x against the 0.2.25 tree beside
it; the second ws54's head; the third trunk's source before it); the runner's speed differs from
VM to VM by up to 2.3x, so ratios compare only within one row. At one
worker the runner reads 1.00–1.05x on connection-per-request and
1.09–1.18x on keepalive. Where the remaining linux gap goes — lobo's
own user space, a fifth of a request, much of it the runtime's string
allocation, and the herd's extra wakeups on connection-per-request — is
docs/PROFILE.md's ws54 addendum.
The kasumi row is indicative: all three sets were refused because
nginx's own five runs spread more than the definition allows. At one
worker kasumi reads about 1.17x and 1.20x, close to the runner; on kasumi the
gap is the sixteen-worker cell's, and
docs/PROFILE.md has why. The earlier rows are kept
as history:
| host | measured | wolf | connection-per-request | keepalive |
|---|---|---|---|---|
| linux x86-64, 4 cpus (GitHub Actions runner) | 2026-10-07 | 0.2.25 | 1.208x | 1.239x |
| linux x86-64, 4 cpus (GitHub Actions runner) | 2026-10-07 | 0.2.24 | 1.151x | 1.255x |
| linux x86-64, 4 cpus (GitHub Actions runner) | 2026-10-02 | 0.2.20 | 1.151x | 1.240x |
| macOS arm64, 18 cpus | 2026-09-09 | 0.2.8 | 1.033x | 1.072x |
| linux x86-64, 4 cpus (GitHub Actions runner) | 2026-09-11 | 0.2.11 | 1.197x | 1.263x |
On linux, listen … reuseport took the connection-per-request ratio
to 1.089x against 1.138x without it on the same VM (2026-09-11); the
tables keep lobo's default. The runner's speed differs from VM to VM,
so ratios compare only within one ledger entry.
The 110.9x linux keepalive figure this page gave for 0.1.0 was a delayed-ACK stall on lobo's two-write response, fixed in 0.1.1.
docs/PROFILE.md has where the time goes. Its
first profile (2026-09-08, macOS arm64, 0.1.0's source built with wolf
0.2.6, pin 398e5f5) measured 63 µs per keepalive request against
nginx's 19; the addenda after it follow each change since, on both
hosts.
Compatibility
docs/directives.md has a row for each of 122 nginx directives: 50 implemented, 47 planned and 25 refused by name when the configuration loads.
differs from nginx's, and where it differs, lobo says so when the configuration loads.
Windows and linux-aarch64 builds do not exist yet. wolf's release tier refuses windows by name, and its native code generator does not serve linux aarch64.
Limits
- Connections.
worker_connections(default 512, nginx's) is the most connections one process holds, counting its listening sockets and one more, as nginx counts them: with onelistena process serves 510 clients at once, andworker_processes 4serves four times that. Past it lobo does what nginx does. Idle connections (one that has sent nothing yet, or a keep-alive connection between requests) are closed to make room, least recently used first, an eighth of them at a time, and the log says[warn] 512 worker_connections are not enough, reusing connectionsat most once a second. With none idle, the new connection is accepted and closed at once, and the log says[alert] 512 worker_connections are not enough, one line each. Upstream connections count as nginx counts them: a proxied request is two connections while its exchange is out. A TLS connection while it is served is not counted (docs/directives.md). Through 0.1.3 the directive was parsed and ignored. - Descriptors. An idle connection holds one descriptor; a
download parked on a slow client holds two (its socket and its
file), two threads and about 150 KB resident. A process holds about
20 at rest, two of them in reserve. Set the limit where lobo is
started (
ulimit -n, systemd'sLimitNOFILE=); lobo cannot set it (worker_rlimit_nofileis refused by name), and warns at start,[warn] 512 worker_connections exceed open file resource limit: 256, when the limit is lower than the connections it may be asked to hold. With downloads, plan two descriptors a connection: 512 connections that are all slow downloads need about 1,050. Out of descriptors lobo does what nginx does:- it stops accepting for half a second at a time and logs
[crit] accept() failed (24: Too many open files)each time, so about two lines a second however many clients wait. On linux they wait in the kernel's queue and are served when descriptors return; macOS drops a connection whose accept failed, one each half second, and the rest wait. Nothing is restarted; - a request whose file cannot be opened is answered 500 (never 404)
and logged,
[crit] open() "<path>" failed (24: Too many open files); - the control endpoint still answers. lobo lets one reserved
descriptor go for the order's connection, and the other while a
reloadorreopenreads and opens files, and takes them back.lobo status,-s reloadand-s stopwork with the process at its limit. Built with wolf 0.2.31 or later (trunk), a process started with too few descriptors for the runtime's own (its readiness set and signal hub, made before lobo's first line) does not start: one line on stderr,wolf: the runtime could not make its signal hub (Too many open files (os error 24)); the program did not start(or… its readiness set …), and exit 71. Measured on linux:ulimit -n4 to 8 gives that line; 9 to 11 lobo's own[emerg](a socket or the pid file it cannot open) and exit 1; from 12 it serves. Through 0.1.3 a process out of descriptors logged nothing, did not answerlobo status, and kept most of a core busy while it waited; and if the first download ever to wait on a slow client came with the process already at its limit, the process ended.
- it stops accepting for half a second at a time and logs
- Threads. A large plaintext body is written by its own proc
beside the loop. Past the host's thread limit a download gets its
head and its body waits for an earlier one to end; wolf's runtime
writes one line to stderr and nothing stops. Measured at 300 tasks:
145 of 200 downloads served, other requests answered. Built with
wolf 0.2.29 nothing else limits how many downloads are parked: 900
in one process were all being served (with
worker_connectionsraised to hold them). Built with wolf 0.2.24 to 0.2.28 (lobo 0.1.2 and 0.1.3) a process served at most4·max(cpus, 2) - 1parked downloads, 15 on 4 cpus; the next got its head and its body waited. Undermemory_budgeta small body runs in a capped proc while fewer than3·max(cpus, 2) - 2downloads are parked: 4 on 1 cpu, 10 on 4, 46 on 16. Past that it is written without the proc, and the budget still rules it. That margin was sized a quarter under the old limit and is kept as lobo's own: it is what keeps the loop out of a proc's join on a host that has run out of threads. - Kept files. A regular file of at most 32 KiB that has not
changed for three seconds is answered from bytes lobo keeps, after
one
statthat must match the file's device, inode, size and both times (plaintext, noRange, nomemory_budget; everything else is read per request as before). Three things it leans on: the filesystem's clock and this host's are one clock (a network filesystem more than three seconds ahead is not), the filesystem keeps a status-change time, and files are published by writing beside them and renaming: one being rewritten through a sharedmmapcan be answered from bytes as old as the kernel's writeback interval. - A range starting at byte N reads and drops N bytes first. Under
memory_budgetaRangeis ignored. AnIf-Rangedate in RFC 850 or asctime form is not parsed. - A proxied request is an exchange beside the loop. Since ws67
(lobo#81; through 0.1.4
proxy_passasked the upstream inside the serving loop, so one silent upstream held every other client of that process forproxy_read_timeout) each exchange runs in a proc beside the loop and reports when it is done: a silent upstream holds its own request and nothing else, and-s stopis obeyed at once. Since ws69 (lobo#100) the procs are a pool kept across exchanges, each on its own loopback job socket. What it costs: about four descriptors and a thread while an exchange is out, two descriptors and a parked thread for each pool proc for five seconds after its last exchange, and on a quiet kasumi, against a prompt upstream, 0.82–0.96x 0.1.4's proxied requests a second with one client in turn (0.62–0.88x under load; ws67's first shape: about half) — two thread wakes an exchange — and 1.06x with 32 clients. A client that closes while its exchange waits ends it at once, as nginx's does (since ws68, lobo#99; behind anhttpsupstream since ws69, but for a TLS read that meets a post-handshake message and waits inside the TLS client for the next record), and a request it pipelines meanwhile is answered next. - Memory. Built with wolf 0.2.28 or 0.2.29 a serving lobo is flat
over requests and connections (0 KB over 4,000 keepalive requests
and over 2,000 connections in the gate), and so is a master supervising
idle workers (it grew about 260 KB a minute through 0.1.3). Under
memory_budgetthe runtime keeps one word for each finished response proc (wolf-lang#671). - Several server blocks. Every
serverserves (through 0.1.3 only the first did), chosen by the address a connection arrived on and its host'sserver_name. Still read from the first server only:client_max_body_size,large_client_header_buffers,memory_budget,tcp_nodelay,access_logandmetrics(-twarns when a later server sets one). Aserver_nameregular expression never matches. A reload re-routes among the addresses bound at start and opens no new one. - An idle lobo is not quiet. A master and four workers with no client make about 2,100 system calls a second built with wolf 0.2.29 (about 1,550 with 0.2.28), where nginx makes 23: each process's wait for a signal reads its pipe every 5 ms (wolf-lang#710).
- No binary swap.
-s reloadre-reads the configuration and keeps the running binary; a new lobo is installed by stopping the old one and starting the new. - Known issues: the serving loop can stop accepting its control socket after a burst of closes on a loaded host (lobo#72, seen once, not located); lobo spends about 2,300 more user instructions a request than nginx (lobo#68). The issues 0.1.2 listed here (lobo#48 to #52 and #55) are fixed in 0.1.3.
Documentation
| GETTING-STARTED.md | start here |
| directives.md | every directive, and every difference from nginx |
| WORKERS.md | prefork, descriptor handoff, distribution measurements |
| CONTROL.md · DRAIN.md | the control socket; reload and draining |
| DRYRUN.md | routing dry-runs |
| LOGGING.md · log-variables.md | structured logs and their variables |
| BUDGET.md | per-vhost memory budgets |
| ARCHITECTURE.md | how it is put together |
| DIFFERENTIAL.md · PARITY.md | the nginx comparison, and the parity definition |
Building from source
wolf-toolchain.toml pins an exact wolf compiler, interpreter and
standard library, and the build refuses any other. That is why the
packages above are prebuilt: a distribution's wolf package moves
ahead of the pin, and a source package depending on it would stop
building at that point.
To build anyway, put the pinned toolchain in .wolf-bin/ and the
pinned nginx in tests/differential/bin/ (the build commands are in
wolf-toolchain.toml and docs/DIFFERENTIAL.md), then run:
tools/lobo-gauntlet # build, both tiers, corpus, differential
tools/lobo-dist # a release archive, packed reproducibly and smoke-tested
Each release archive includes a BUILD file with the toolchain, source
commit and pins it was built from.
License
GPL-3.0-or-later. The wolf Training Data Permission lets you train models on this repository's text and ship excerpts of it in datasets under CC BY 4.0.