m0serve Realtime from a synchronous Python app

Flags and a doctor for the host — 2026-09-19

A Mojo host application was configured by environment alone. That was decided, not overlooked: D29 recorded "no CLI flags and no --doctor" with the retiring condition "a flag its operator cannot set in the environment", and the developer product (host plan, Phase 6) is that condition — a CLI whose doctor delegates to the binary's own needs the binary to have one. This round gives the host m0serve's command-line contract: flag over variable over default, strict parsing, and a doctor that exits with the server's own code. It is SPEC E30 and E31.

What was built

m0_host/flags.mojo is the parser, pure over a list of strings. serve[H, P](config) now begins by laying the command line over the config it was handed, so serve(AppConfig()) is still an application's whole main. The flags are what AppConfig already carried — host, port, workers, threads, blocking threads, access log, heartbeat, tick, keep-alive cap, QoS, --spawn-workers (read, then refused exactly as its variable is) — plus --doctor and --help.

Report, the pure half of m0serve's doctor, moved from m0-wsgi to m0_http.doctor with its first key made a parameter, so the two doctors render one shape: ok, exit, grouped facts, and checks whose failures each carry a fix and the exit they cause. m0_wsgi.doctor re-exports it.

Question 1: where does the command line get applied?

In serve, because that keeps the one-line main. But every host app in the tree prints its own address BEFORE it calls serve, from the config it just built — so under --port 9000 the banner would name 8080 and the server would bind 9000.

AppConfig() could not simply start reading argv: m0serve builds one too (AppConfig(default_port=DEFAULT_PORT)), and would have its own arguments parsed twice by two grammars. So there is host_config()AppConfig() with the command line applied, the same exits — for an application that prints an address, and the overlay is idempotent, which is what lets serve apply it again without asking whether someone already did. The six host apps with a banner take host_config(); a scaffold with no banner keeps AppConfig().

The two-argument serve(config, server_config) has the same problem one level down: apps/sim_loop builds its ServerConfig and sets its own tick before the flags are read. HostFlags.apply_to lays over it only the flags that were GIVEN — the operator's explicit word outranks the application's code; a flag nobody typed leaves the application's value alone.

Question 2: is --threads 0 a usage error or a refusal?

m0serve answers its --workers 0 with 2. The host already refused M0_THREADS=0 with 78, and a host that answered the flag with 2 and the variable with 78 would have two descriptions of one rule. So the parser refuses only what it cannot READ (not a number, a port out of range, an unknown flag, a positional), and a count that cannot be SERVED goes down the one path whichever way it arrived.

That path is host_checks: every rule the host refuses by, in the order it applies them, each evaluated. host_refusal — what serve exits 78 on — is its first failure, and the doctor lists them all and exits on the same first failure. m0serve's doctor mirrors main's check order by hand and says so in a comment; here the server and the doctor read one list, so they agree by construction. The gate runs both anyway, for the reason a by-construction argument is worth one sabotage: reversing the loop in host_refusal is caught only by a configuration that trips two rules at once, which the first version of the probe did not have.

Each refusal now prints its fix, and the fix names both spellings (set --threads (M0_THREADS) to 1 or more), because the operator may have used either.

Question 3: what does the doctor print when the application prints too?

One JSON object, as the last line of stdout. An application's banner comes first, and its OWN refusal (apps/blobs checks its cadence in main and exits 78 before serve) produces no report at all. That second fact is what settles the first: a caller already has to handle "exit 78 and no JSON", so "take the last line, try JSON" is the contract, and the application is not asked to know whether it is being doctored.

That is also how the contract holds for application-owned configuration without a hook: those checks run before serve, so they fire identically under the doctor. What the doctor does not do is RENDER the application's settings; that needs a hook, and no second application has asked.

The report's first key is "m0_host":"1" — a format number. The release version lives in pyproject.toml and cli.mojo and nowhere else (docs/RELEASING.md), and a third copy compiled into every app was not worth what it would say. The m0 wheel can add its own version from outside.

What the doctor does not cover

The bind, and the application's make. Whether the address is free and whether the handler can be built are found out by doing them; a doctor that built the handler would open the application's database to report on a port. Both fail at run time with a named line (make with 78, D30), and E31 says so rather than claiming the whole contract.

What is gated

smoke-host-doctor (every PR) is smoke-doctor's shape: twenty configurations of apps/host_check, each run as --doctor ARGS and as ARGS, the two exit codes required to agree with each other and with the table. A served row is really served — /health on the port the FLAG names while M0_PORT names another, which nothing may then answer on.

The sabotage found a hole in the gate before anything else did. Two of the first twelve rules were MISSED: serving the environment under a command line that had been read, and withholding a given flag from the ServerConfig. Both passed because the gate app takes host_config(), which applies the command line before serve sees it — the banner fix had hidden whether serve does its own job. The probe now runs the flag rows in both application shapes (M0_HOSTCHECK_ENV_CONFIG=1 hands serve a bare AppConfig()), and both rules are caught. Twelve rules in sabotage-host, seven on the wire and five against the parser's unit tests.

The spec sheet's closed-set rule covers the new flag set too: every flag flags.mojo accepts must be named by a row, and a row may name a flag either command line accepts.

Not built

  • The application's own settings in the report (a hook; wait for the second application that wants it).
  • Flags an application defines for itself. The parser is strict, so an unknown flag is exit 2; an application's settings are its variables.
  • --version. There is no version to print (above).