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

What melee cannot do

The honest list: what the process model rules out permanently, what is simply not built yet, and when you should use something else.

Read this before you commit an app to melee. Some of these are consequences of the design and will not change; others are gaps with work planned behind them. They are marked differently on purpose.

By design means it follows from compiling ahead of time and running one process per request. Not built means it is intended and missing.

The process model

By design — no background work in your app. No threads, no Thread.new, no Queue, no job runner. The build step refuses Thread and Queue by name. A request child exits when the response is written, and there is nothing left to hand work to. The replacement is a durable object with a timer — see Background jobs and scheduled work.

Not built — nothing schedules fibers. The platform rule is “fibers, not threads”, and the intent is that the library’s I/O suspends the current fiber. Nothing implements that today, so in practice there is no concurrency inside a request at all.

By design — nothing in memory survives a request. Instance variables, class variables, memoised values, an in-process cache, a connection pool: all per-request. Only constants computed at the top level survive (they are built once in the warm process and inherited through fork).

No concurrency inside a request. Two outbound HTTP calls run one after the other. Ten feeds is ten sequential fetches.

By design — no shared state between requests except the database. There is no Rails.cache, no Redis, no memcached, no shared memory. A cache is a table, or a durable object.

The dialect

By design. melee apps are compiled whole-program, so anything that decides what to call at run time is out: eval, method_missing, define_method with a computed name, send with a computed name, Class.new, ObjectSpace, binding. Dispatch with case.

Also: no gems, ever. The available requires are a fixed list (json, base64, digest, securerandom, uri, net/http, openssl, set, csv, strscan, optparse, pathname, tmpdir, forwardable), plus require_relative for your own files. Never stringio.

Smaller ones that bite: string literals are frozen; there is no Date class and no Time.parse; a rescue clause must name its class in full rather than through a constant alias; a Proc fetched out of a Hash or Array cannot be called; Exception#backtrace and caller return [].

The Ruby that compiles is the complete list, with a workaround for each.

And the build checks less than it looks like it does. An undefined method reached through an explicit receiver — db.frist(...), "x".nope, Household.get("home").refrsh — compiles cleanly and raises NoMethodError when the line runs. So do wrong arity and anything called on nil. A green melee check is not a working app.

Missing from the app surface

File uploadsNot built. Multipart bodies are not parsed at all — a file input’s fields arrive empty — and the request body is capped at 8 MB.
Object storageNot built. HTTP has no put or delete and no request signing, so you cannot hand bytes to S3 either. Designed, with presigned URLs, not started.
EmailNot built. Nothing sends mail. An HTTP API for a mail provider is the only route today.
WebSockets, SSE, long pollingNot built. An app cannot open a listening socket, and a request is a short-lived process under a 30-second cap.
Streaming responsesPartly. A route can produce a streamed body, but it is collected and then sent rather than passed through as it arrives.
later / every on objectsNot built. You re-arm the timer inside on_timer yourself.
Routing straight to a durable objectNot built; under design. Every entry point is a route today.
A cache helperNot built. Use setting, a table, or an object.
MiddlewareNot built. before filters with a path prefix are what there is.
Sub-second timeoutsNot built, and blocked: timeout: is rounded up to whole seconds because the compiler truncates a Float at the FFI boundary, so 0.5 would reach net/http as 0, which it reads as no timeout.

The numbers

Defaults an operator can change, but that your app lives inside:

Requests at once, per app4 (slots)
Request timeout30 s, then the process group is killed and the caller gets a 504
Request body8 MB
Memory per app128 MB (memory.max, root only)
Processes per app256 (pids.max)
Idle stop5 minutes with nothing in flight
Cold start after thatabout 2 ms
Database writersone at a time; a second waits 5 s and then raises
Durable object callsone at a time, per app, across every object
Redirects followed5
Static file cachemax-age=300, no fingerprinting

Operating it

This is where melee is least finished.

  • Not built — no TLS. melee-server speaks plain HTTP on both its ports. TLS is something you put in front (Caddy is the intended answer). Until you do, the control-API token and every melee env value cross the network in the clear.
  • Not built — no installable CLI. melee finds the standard library through a checkout of this repository. There is no package, no brew install, and the compiler is a git submodule you build yourself.
  • Not built — no deployment recipe. No systemd unit, no guidance on where the home directory lives, no log rotation, no upgrade path for the server or the compiler pin.
  • Not built — no backups. The database is a file on one machine’s local disk and nothing copies it anywhere.
  • Not built — single machine. No replication, no failover, no placement, no custom domains. An app is on one host, and that host is a single point of failure.
  • Not built — no rollback command and no way to delete an app. Deploying the previous source again is the rollback; removing an app means deleting directories on the server by hand.
  • Not built — no metrics. Logs are what there is: no request counters, no latency histogram, no health beyond /v1/health.
  • By design for now — the build runs on the server, as the server’s user, unsandboxed, with the toolchain on its PATH. It compiles source you pushed. That is fine when you are the only person who can push and not fine otherwise.

Security, stated plainly

The sandbox around a running app is real: a uid per app, user and mount namespaces, a Landlock path allow-list, a seccomp allow-list of 85 syscalls, and cgroup limits. One app cannot read another’s database, environment or session secret, and cannot signal its processes.

What is not closed:

  • An app can make outbound connections anywhere, including loopback and private addresses. If your app fetches a URL a visitor supplied, that is an SSRF and nothing stops it.
  • The build step is not sandboxed.
  • An app can enumerate the host’s processes (no PID namespace, no private /proc), though it cannot read or signal them.
  • The control API is a TCP port on loopback with a bearer token; moving it to a UNIX socket is open work.
  • A uid is never retired, so a deleted app’s id is not safely reusable.

The threat model and the full findings table live in docs/design/security.md in the repository. The short version: melee is currently safe to run your own code on a machine you control, and is not yet ready to accept apps from people you do not trust.

When to use something else

Use a normal server and Rails (or anything else) when:

  • the app needs gems, or a library that only exists as a gem;
  • it needs Postgres, a connection pool, or more than one writer;
  • it needs background workers, real queues, or fan-out;
  • it needs file uploads, or to serve large media;
  • it needs to stay up when one machine does not;
  • it holds data you cannot afford to lose and cannot back up yourself;
  • it is going to keep growing.

melee is for the app that is small, mostly idle, and would otherwise not be worth deploying at all. Inside that shape it is genuinely good. Outside it, it will fight you.

See also