Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

How a request reaches your app

From the browser to your route block and back: one supervisor, one warm process per app, and one short-lived child process per request.

You do not need this page to write an app. You need it when you are wondering why an instance variable did not survive, why there is no background thread, or why the app took two milliseconds to answer the first request and a fifth of a millisecond after that.

The pieces

flowchart LR
  browser["browser"] -- "HTTP" --> front
  subgraph host["one Linux host"]
    front["melee-server<br/>terminates HTTP, routes by Host:"]
    warm["warm process<br/>your binary, sandboxed<br/>one per app"]
    child["request child<br/>one request, then exit"]
    worker["worker process<br/>durable objects and timers"]
    data[("data/<br/>SQLite")]
    front -- "a frame on a free slot" --> warm
    warm -- "fork" --> child
    child --> data
    child -- "Call / Result" --> worker
    worker --> data
  end
  cli["melee CLI<br/>on your machine"] -- "push: a tarball" --> front

Two programs make up the platform: melee-server, which runs on the host and owns every port, and melee, the command-line tool you run on your own machine. Your app is a third program — a binary of its own — and the melee library is compiled into it rather than running as a service.

Deploying

melee push tars up your directory and sends it to the server’s control API. The server does not trust anything in the tarball except the Ruby, the templates, the migrations and the files in public/; a build/ directory is refused outright, so an uploaded executable can never become the app.

flowchart TD
  A["melee push<br/>tar.gz of the app directory"] --> B["new release directory"]
  B --> C["compile views/*.erb to Ruby<br/>strict locals become keyword arguments"]
  C --> D["check the Ruby: syntax, render calls against declared locals,<br/>migrations against a scratch database"]
  D --> E["spin build<br/>Spinel compiles your app + the melee library + SQLite<br/>into one native binary"]
  E -- ok --> F["swap the 'current' symlink<br/>stop the old warm process"]
  E -- error --> X["422 with file:line: message<br/>nothing is deployed, the old release keeps serving"]

Two things about this are unusual and worth holding on to.

The compile is whole-program. Spinel reads your app, the library and everything they call together and infers types across the whole thing, which is why a rebuild is a few seconds rather than instant. It is also why the dialect is restricted: a program that can invent method names at run time cannot be compiled this way. It catches less than you might hope — see what a green build does not prove.

A failed build deploys nothing. The previous release keeps serving, and the diagnostics come back to your terminal as file:line: message, mapped back to the .erb you wrote rather than the Ruby it was compiled into.

Serving

When a request arrives for an app whose process is not running, the server starts it. That is the warm process: your binary, sandboxed, sitting in a loop doing nothing. It is not handling requests. Its only job is to be already initialised — classes defined, constants built, templates compiled in — so that answering a request costs a fork rather than a program start.

sequenceDiagram
  participant B as browser
  participant S as melee-server
  participant W as warm process
  participant C as request child

  B->>S: GET /notes  Host: notes.example
  S->>S: Host → the app "notes"; read the body (≤ 8 MB)
  alt nothing running
    S->>W: start the binary, sandboxed, with N sockets on fds 3..3+N
  end
  S->>S: take a free slot
  S->>W: a Request frame on that slot
  W->>C: fork
  C->>C: your before filters, your route block, your template
  C->>S: a Response frame, then exit(0)
  S->>B: the HTTP response

fork is the Unix call that makes a copy of a process. The copy — the child — starts life with everything the parent had: the compiled templates, the open sockets, the sandbox. Copying is cheap because the kernel does not actually copy the memory, it just marks it to be copied if either side writes to it. The fork itself measured about 0.09 ms in isolation on Linux; a whole warm request through the real server — HTTP, frames, fork, router, session — is 0.21–0.27 ms.

The child handles exactly one request and exits. This is the single most important thing on this page. It is why:

  • params, session, request and log can be top-level methods with nothing passed around — there is one request in the process, so “the current request” is unambiguous;
  • an instance variable, a class variable or a memoised constant set during a request is gone afterwards;
  • a slow request cannot poison anything, and a crashed request cannot corrupt the next one;
  • there is nowhere to run a background thread, because the process you would start it in is about to exit.

The warm process itself stays alive between requests, but your code does not run in it. It only selects on its sockets, forks, and reaps the children that finish.

Slots, and how much runs at once

The server hands the warm process a fixed number of socket pairs at start-up — four by default. A socket pair is just two connected file descriptors, like a pipe that works in both directions; the server holds one end and the app holds the other. One in-flight request occupies one pair, so the number of pairs is the app’s concurrency limit. Requests beyond that queue briefly, and then get a 503 with Retry-After: 1 if the queue is also full.

When things go wrong

What happensWhat the caller sees
The child crashes, or the sandbox kills it for a forbidden syscall502, within about a second
The request takes longer than 30 seconds504, and the whole process group is killed
Every slot is busy and more than eight requests are already queued on one503 with Retry-After: 1
fork fails because the app is at its process cap (pids.max, 256)503
Nobody has asked for 5 minutesThe process is stopped; the next request restarts it in about 2 ms

The timeouts and the slot count are the operator’s flags, not the app’s; see Running melee-server.

The sandbox

All of this is set up once, around the warm process, before your code runs — so every request child inherits it through fork. On macOS none of it exists, which is why melee dev is a development tool and the real thing is Linux.

flowchart TB
  s["melee-server"] -- "clear the environment, then fork" --> w
  subgraph w["between fork and exec, in the child"]
    direction TB
    a["a uid and gid of this app's own"]
    b["user and mount namespaces"]
    c["Landlock: an allow-list of paths<br/>read its own release, write its own data directory"]
    d["seccomp: an allow-list of 85 syscalls<br/>anything else kills the process"]
    a --> b --> c --> d
  end
  w -- exec --> run["your binary"]
  s -- "then, from outside:<br/>put the pid in a cgroup<br/>memory.max, cpu.weight, pids.max" --> run
  run -- fork --> child["request children inherit all of it"]

Four of those are applied by the child itself between fork and exec, so they are in place before a single line of your code runs, and every request child inherits them. The environment is cleared by the server before the fork, and the cgroup is applied by the server afterwards, by writing the new process id into it.

In one line each:

  • A uid of its own. Every app gets its own Unix user id from a range the operator sets. That is what file permissions, signals and /proc are built on, so it keeps one app out of another’s processes. This one is conditional: it needs the server to be running as root with --app-uid-range. Without that every app runs as the server’s own user, and the server says so loudly at startup.
  • Namespaces give the process its own view of users and of the filesystem mount table, so it is not looking at the host’s.
  • Landlock is a Linux kernel feature that fences a process into a list of paths. melee grants read on the system libraries and this app’s own release, read-write on this app’s data directory, and nothing else — so even a path the uid would be allowed to read is refused.
  • seccomp filters system calls. melee allows 85 — the ones a Ruby program doing files, sockets and memory needs — and kills the process on anything else. listen is not among them, so an app cannot turn a socket into a listener and cannot open a port of its own. (accept is allowed, but only because the worker answers on a UNIX socket that melee-server binds and hands over; the app never creates one.)
  • cgroups are the kernel’s resource accounting. Each app gets a memory ceiling (128 MB by default), a CPU share, and a cap on how many processes it may have.

The two credential mechanisms overlap on purpose: the uid covers processes and signals, Landlock covers files, and getting at another tenant’s data means defeating both.

Where things are kept

<home>/apps/<name>/
  releases/<id>/source/    the push
  releases/<id>/bin/<name> the binary
  current -> releases/<id> swapped atomically on deploy
  data/                    your SQLite databases and anything else you write
  env                      the values melee env set, delivered as environment variables
<home>/logs/<name>.ndjson  one JSON object per line

There is no database in the server. The filesystem is the registry, and data/ is the only place an app can write.

The worker, for things that outlive a request

A request child cannot hold state and cannot run in the background. So melee runs a second long-lived process per app — the worker — in the same sandbox, and durable objects live there.

flowchart LR
  child["request child"] -- "Call over data/worker.sock" --> worker["worker process"]
  worker -- "Result" --> child
  worker --> objdb[("one SQLite file per object")]
  worker -- "my next timer is at T" --> server["melee-server"]
  server -. "starts the worker at T, even if nothing is being requested" .-> worker

Household.get("home").refresh in a route is a call across that socket: the arguments go over as JSON, the method runs in the worker, and the return value comes back. Handles are generated at build time from your class, so the set of callable methods is fixed — though a misspelling still raises at run time rather than at build time, like any undefined method. The worker runs one call at a time.

Because the worker tells the server when its next timer is due, an app with a timer wakes up on its own schedule without anyone visiting it. That is the whole of melee’s job and cron story — see Background jobs and scheduled work.

How fast, in numbers

Measured on the Linux development VM, sandboxed:

Warm request, through HTTP, frames, fork, router and session0.21–0.27 ms
First request after a deploy or an idle stop1.6–2.6 ms
Throughput, 500 requests at 50 concurrent7,700 requests/s
Warm process resident memory, after that load4.8 MB
A whole deploy through the control API (unpack, templates, compile, activate)10 s
spin build alone, after a Ruby changeabout 4 s

Next