Long-running server: argv as configuration

Copy Markdown View Source

An escript and a Mix task both assume the process exits when the command returns. A server does not: argv configures a supervision tree that keeps running afterwards. The command tree is declared the same way; only the entry point changes.

Full runnable project: examples/server/.

The entry point

Application.start/2 reads argv, and the parse result decides what to supervise:

defmodule Server.Application do
  use Application

  @impl true
  def start(_type, _args) do
    case Cheer.parse(Server.CLI, Cheer.argv(), prog: "server") do
      {:ok, Server.CLI.Serve, args} ->
        children = [{Server.Listener, args}]
        Supervisor.start_link(children, strategy: :one_for_one, name: Server.Supervisor)

      :handled ->
        System.halt(0)

      {:error, :usage} ->
        System.halt(2)
    end
  end
end

mod: in mix.exs is what makes this the entry point:

def application do
  [
    extra_applications: [:logger],
    mod: {Server.Application, []}
  ]
end

Why parse/3 and not main/3

Cheer.main/3 halts the VM. That is the whole point of an escript entry point and exactly wrong here: start/2 has to return {:ok, pid} for the VM to keep running.

Cheer.parse/3 stops after resolution and validation and hands back the matched command with its args, so the command tree describes the interface and the supervision tree consumes the result. run/2 is never called.

The command has no handler

A command resolved this way has nothing to dispatch to, so parse_only() says so and the compiler stops asking for a run/2:

defmodule Server.CLI.Serve do
  use Cheer.Command

  command "serve" do
    parse_only()

    about "Start the server"

    option :port, type: :integer, short: :p, default: 4000, help: "Port to listen on"
    option :transport, type: :string, default: "http", choices: ["http", "stdio"],
      help: "Transport to serve"
  end
end

Without it, a leaf command with no handler warns, which fails a build using mix compile --warnings-as-errors and forces exactly the stub this shape is meant to avoid. Cheer.run/3 on a parse_only command raises and names parse/3, rather than dying on the missing callback.

It is per command, not inherited: declare it on the leaves that have no handler. A tree with a mix of both is fine, and so is declaring it on a command that implements run/2 anyway.

The run/3 alternative

Cheer.run/3 also returns rather than halting, so it works too. The cost is that the leaf handler becomes a stub whose only job is to smuggle the parsed options back out through its return value:

case Cheer.run(Server.CLI, Cheer.argv(), prog: "server") do
  {:serve, args}   -> Supervisor.start_link(children(args), strategy: :one_for_one)
  :ok              -> System.halt(0)
  {:error, :usage} -> System.halt(2)
end

That shape has a second problem: run/3 returns :ok for --help and --version, which is indistinguishable from a handler that legitimately returns :ok. parse/3 reports that case as :handled instead, so the caller does not have to constrain what its own handlers may return.

Use run/3 when the handlers do real work and returning a config value is incidental; use parse/3 when argv is configuration.

Halting, and exit codes

start/2 has no "started nothing, exit cleanly" reply, so the cases with nothing to supervise halt explicitly. 2 for a usage failure is the code Cheer.main/3 uses; Cheer.exit_code/1 is the same mapping if you would rather not restate it:

result ->
  System.halt(Cheer.exit_code(result))

Burrito

Under Burrito, the wrapper boots the BEAM and starts your applications. It never calls a main function, so main_module in the release config is metadata: Application.start/2 is the real entry point, and that is where argv has to be read.

A Burrito binary also does not populate System.argv/0. Its arguments arrive through Burrito.Util.Args.argv/0, which is what Cheer.argv/0 returns when Burrito.Util.running_standalone?/0 is true.

The guard matters as much as the call. Keying off "is Burrito loaded" rather than "is Burrito driving this run" hijacks argv under mix test and iex -S mix, where the arguments belong to the test runner or the shell.

Getting the argv source wrong fails silently rather than loudly: System.argv/0 returns [], every option falls through to its default, and the binary does something plausible but wrong. A --transport stdio that quietly stays on the :http default starts a web server while the client waits forever for a handshake.

start/2 runs in dev too

Every way of starting the application runs start/2, including iex -S mix and mix test. Those pass no argv of their own, so a bare command tree prints help and halts, taking the shell or the test run with it.

When that is a problem, gate the dispatch:

@impl true
def start(_type, _args) do
  if Application.get_env(:server, :dispatch_argv?, true) do
    dispatch(Cheer.argv())
  else
    Supervisor.start_link([], strategy: :one_for_one, name: Server.Supervisor)
  end
end
# config/test.exs
config :server, dispatch_argv?: false

Testing

parse/3 makes the command tree testable without starting anything: it is a pure question about argv, and the supervision tree never enters into it.

test "serve defaults to http on 4000" do
  assert {:ok, Server.CLI.Serve, args} = Cheer.parse(Server.CLI, ["serve"])
  assert args.port == 4000
  assert args.transport == "http"
end

test "an unknown transport is a usage failure" do
  assert Cheer.parse(Server.CLI, ["serve", "--transport", "carrier-pigeon"]) ==
           {:error, :usage}
end

Cheer.Test.run/3 still covers the handler path for trees where the handlers do work. See Testing.

Run it

cd examples/server
mix deps.get

mix run --no-halt -- serve
# [info] listening on port 4000 over http

mix run --no-halt -- serve --port 8080 --transport stdio
# [info] listening on port 8080 over stdio

SERVER_PORT=9000 mix run --no-halt -- serve
# [info] listening on port 9000 over http

mix run -- --help
# Usage: server <COMMAND>
# ... (exits 0)

mix run -- serve --transport carrier-pigeon
# error: --transport must be one of: http, stdio
# (exits 2)

What it shows

  • Cheer.parse/3 -- resolution and validation without dispatch, so argv can configure a supervision tree.
  • parse_only() -- the leaf declares that it has no handler, so no stub run/2 and no compiler warning.
  • Cheer.argv/0 -- the right argv under mix run, an escript, and a Burrito binary.
  • :handled -- help and version reported distinctly from a handler that returns :ok.
  • Explicit halts -- start/2 returns {:ok, pid} or the VM exits; there is no third option.
  • The dev-shell caveat -- start/2 also runs under iex -S mix and mix test.

See also

  • Cookbook: Greeter -- the same declarations as an escript that exits when the command returns.
  • Cookbook: Mix task -- the other entry point that must not halt the VM.
  • Guides: Testing, Help and output.