The host
What a Mojo application's main hands over: handlers and producers, workers, threads and a handler pool, flags, the doctor, every refusal and exit code.
m0_host is what a Mojo application's main calls. It owns the listener,
the workers, the signals and the drain; the application supplies a handler
and, if it has work on a cadence, a producer.
from m0_host.flags import host_config
from m0_host.host import ViewsApp, serve
from views import Items
def main() raises:
serve[ViewsApp[Items]](host_config())
What an application supplies
serve[H, P](config) takes two types.
H: AppHandler is an HTTPService with a static make(ctx). The host
calls make once per worker, after the fork, so each worker builds its own
handler. Three more static methods have defaults:
| method | default | what it declares |
|---|---|---|
page_slots(workers) -> Int |
0 | words of shared memory, created before the fork and handed to every make as ctx.page |
max_workers() -> Int |
0, any number | the most processes this application serves from |
max_threads() -> Int |
max_workers() |
the most loops in one process |
An application whose state lives in the handler answers max_workers() -> 1.
M0_WORKERS=2 is then refused at startup: two workers would hold two
different copies.
ViewsApp[S] is the AppHandler for an application that is a
views table. S: ViewState has make(ctx) and urls(),
and the same three optional methods. Both scaffolds use it.
P: Producer is work on a cadence, off the event loop: make(ctx), and
step(mut self, mut out: Publisher) -> Int, which returns the nanoseconds
until the next step. One producer runs, on a thread of worker 0, and
out.publish(channel, id, frame) reaches every worker's streams. Number
frames with out.next_id(): the counter is shared memory, so it survives a
worker being replaced. publish returns False for a frame a channel
refused; count those. NoProducer is the default.
HostContext, the argument to every make:
| field | meaning |
|---|---|
worker, workers |
this worker's index and the count; processes or loops |
threaded |
whether the workers are loops on threads of one process |
thread |
-1 for the loop's own handler, 0 upward for a pool thread's |
capacity |
the connection capacity; a stream registry needs at least this many slots |
page |
address of the application's shared page, or 0 |
bus |
every worker's channel, for a handler that publishes to its peers |
config |
the resolved configuration |
A make that raises, in a handler or a producer, ends the process with
exit 78 and its message. It is not retried.
Execution modes
One loop in one process is the default.
M0_WORKERS=N forks N processes that share the listener. A worker that
dies is replaced. The worker that wins an accept hands the connection to
the least-loaded sibling, so keep-alive load spreads.
M0_THREADS=N runs N loops on N threads of one process. Nothing is
forked, so a runtime that cannot survive a fork (Core ML, CoreFoundation)
works, and the loops share an address space. There is no supervisor: a loop
that dies takes the process. It is refused beside M0_WORKERS above 1.
Measured against each other, threads and workers are level on throughput
(0.95x to 1.03x) and on the tail, and threads use a fifth to a third less
memory. Reach for workers first; use threads for one of the reasons above.
ctx.worker and ctx.workers count either, so an application runs under
both unchanged.
M0_BLOCKING_THREADS=N puts N handler threads behind each loop, and
composes with either mode. The loop keeps accepting and parsing while a slow
view runs on a pool thread. Each pool thread builds its own handler with
make (ctx.thread says which). A view registered with add_loop, or
on_loop=True, is answered on the loop and never queued. A stream must be
opened on the loop: one begun on a pool thread is refused with 409.
Configuration
A flag overrides its M0_ variable, which overrides the default.
| flag | variable | default |
|---|---|---|
--host ADDR |
M0_HOST |
0.0.0.0 |
--port N |
M0_PORT |
8080 |
--workers N |
M0_WORKERS |
1 |
--threads N |
M0_THREADS |
1 |
--blocking-threads N |
M0_BLOCKING_THREADS |
0 |
--access-log |
M0_ACCESS_LOG |
off |
--sse-heartbeat-ms N |
M0_SSE_HEARTBEAT_MS |
15000 |
--app-tick-ms N |
M0_APP_TICK_MS |
0, off |
--max-keepalive-requests N |
M0_MAX_KEEPALIVE_REQUESTS |
1000 |
--qos |
M0_QOS |
off; macOS, keeps the loop on performance cores |
--spawn-workers |
M0_SPAWN_WORKERS |
m0serve's; refused here |
--doctor |
print the configuration, start nothing | |
--help |
host_config() is an AppConfig with the command line applied, for an
application that prints its own address before serving. serve(AppConfig())
applies the flags itself.
Exit codes and refusals
| code | meaning |
|---|---|
| 0 | served, then drained on SIGTERM or SIGINT |
| 1 | a failure while starting or serving: the address cannot be bound, a loop raised, a loop did not finish its drain |
| 2 | a command line that cannot be read: an unknown flag, a value that is not a number |
| 78 | a configuration that was read and will not be served |
Every 78 is one line on stderr: what was found, then the fix in parentheses. The checks, in the order the host applies them:
| check | refused when |
|---|---|
workers-count |
M0_WORKERS is below 1 |
workers-vs-application |
M0_WORKERS is above the application's max_workers() |
threads-count |
M0_THREADS is below 1 |
workers-vs-threads |
M0_WORKERS and M0_THREADS are both above 1 |
threads-vs-application |
M0_THREADS is above the application's max_threads() |
spawn-workers |
M0_SPAWN_WORKERS is set: the host forks without exec |
spawned-marker |
M0_WORKER_SPAWNED is inherited from an m0serve worker |
A count is refused the same way whether it came from a flag or a variable.
The doctor
bin/server --doctor prints one JSON object as the last line of stdout and
binds nothing. It holds the resolved configuration, where each value came
from (flag, env or default), the topology that adds up to, what the
application declares (max_workers, max_threads, page_slots), and a
checks array in which every failure carries its fix. The API key is
never printed. It exits with the code serving would exit with for the same
arguments, because serve and the doctor read the same list of checks.
uv run m0 doctor runs the toolchain's checks first (platform,
mojo-installed, mojo-gated, c-compiler, project), then this one.
Shutdown
SIGTERM and SIGINT drain: in-flight requests finish, held streams are
closed from the server, and the process exits 0. The drain, the handler
pool's join and the producer's join share one five-second bound. A thread
still running after it is named on stderr and the process exits anyway, so
docker stop never waits for SIGKILL. The binary runs correctly as PID 1.
The capability rows are E21 to E31 in Capabilities. The design is recorded in the Mojo host, loops on threads and flags and a doctor for the host.