We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
iex -S mix phx.server: What? How?
iex -S mix phx.server: it starts up my Phoenix dev server under Elixir’s interactive shell program, IEx, so I can call functions from my app and whatnot.
One day, it started to bug me unreasonably much that I didn’t really understand how this command causes that to happen. Perhaps culpable: a touch of delirium, downstream of the flu.
There were a lot of reasons for my mystification (i.e. a lot of basic things I hadn’t thought much about before), so below you’ll find, if you want to, ~2700 words about some things that happen and how. I don’t expect even one whole person to read the entirety. It’s not that kind of blog post.
There may be inaccuracies.
Interacting scripts
Before getting fancy, I want to know what my basic everyday Elixir commands do.
The elixir, iex, and mix commands are all scripts.
elixir, the script
The elixir shell script constructs a string of arguments based on the arguments you supply, plus some information about your Elixir installation, and tacks that onto an erl command.
erl comes with the Erlang distribution. It bootstraps an Erlang runtime environment, including starting a BEAM instance for everything to run on, and gets it to do the things indicated by the arguments. Unless you tell it not to, erl also supplies, and drops you into, the Erlang interactive shell.
The elixir script ends with
if [ -n "$ELIXIR_CLI_DRY_RUN" ]; then
echo "$@"
else
exec "$@"
fi
It’s assembled the erl invocation, with all its arguments, into "$@", and all that’s left is to execute that command.
But look! By setting the ELIXIR_CLI_DRY_RUN env var, you can view the whole assembly instead of running it! I didn’t know that; I’m going to use it in a minute.
mix, the script
The mix script is exactly this:
#!/usr/bin/env elixir
Mix.CLI.main()
This is an Elixir script! The shebang makes mix a (much) shorter way to write (in my case, with runtime installations managed by asdf):
elixir /Users/chris/.asdf/installs/elixir/1.17.3-otp-27/bin/mix
So mix invokes elixir and we end up, again, with an erl command.
This felt circular to me, because the elixir script doesn’t do anything with the contents of the mix script. But that’s just the way it works; rest assured that something eventually reads the file. We’ll see what, later.
iex, the script
The iex shell script ends with
exec "$SCRIPT_PATH"/elixir --no-halt --erl "-user elixir" +iex "$@"
Which is to say: it calls the elixir command with a specific set of arguments, followed by any arguments it was called with ("$@").
Another erl command.
elixir --help has entries for --no-halt (“[do] not halt the Erlang VM after execution”) and --erl (“[s]witches to be passed down to Erlang”). That +iex option isn’t in the help, but the elixir script does notice it, and omit -s elixir start_cli from its erl arguments.
But we don’t have to work out the final set of arguments by reading the scripts. We can just print them out by setting the ELIXIR_CLI_DRY_RUN environment variable.
Pause to appreciate coolness
My Erlang/OTP installation doesn’t know anything about my Elixir installation.
The arguments in these human-readable erl commands will have to tell it, or point it at, everything it needs to know in order to do whatever Elixir-land thing I wanted. This is not trivial or banal. It is very cool.
How to read erl arguments
So: elixir runs erl with a collection of arguments. And iex and mix both go through elixir. That is, each of these commands generates an erl command.
The erl docs do a pretty good job of explaining the different kinds of argument that erl accepts, and what it does with each kind.
It can be a bit hairy to categorise them in practice. The following paraphrases info that’s in the docs, with some emphasis on “subtleties” that I missed in my first reading, and that would have saved me some casting about in source code.
-
Emulator flags are for VM settings. Emulator flags start with a plus sign, except when they start with a hyphen like all the other flags. We won’t run into any emulator flags in this exercise.
-
Many, but not all, other defined flags are marked as init flags: flags that are interpreted and used by Erlang’s
initand not stored for later use. -
--and-extraare init flags that indicate that what follows is one or more plain_arguments: values that theinitstores in a list that you can get later withinit:get_plain_arguments/0. -
You, the user, can create user flags to suit your needs. The
initstores user flags as key-value pairs, where the flag becomes an atom key for a value that’s a list of the items that follow (up until the next flag). To get this list, useinit:get_arguments/0. If an argument starts with a hyphen, and it’s not an init flag, an emulator flag or a plain argument, it’s a user flag. -
Some code that comes with the Erlang distribution looks for arguments stored with specific user flags. Some of these user flags are documented alongside the init flags.
-
Case in point: there’s an important special-case user flag documented as
-Application Par Val; when/if the OTP application namedApplicationis started,Application‘sParconfiguration option is set toVal. We see this shape of user flag in theelixircommand. This one tripped me up; I could see the apparent intent but missed it in the docs.
elixir, mix, iex: the erl angle
Armed with the above clues to how erl statements work, let’s look at minimal examples of each of our Elixir commands.
elixir
If I don’t put something after elixir, it just prints its help at me, so I’ll tell it to print hi instead.
ELIXIR_CLI_DRY_RUN=1 elixir -e 'IO.puts("hi")'
Here’s what that spits out:
erl -noshell \
-elixir_root /Users/chris/.asdf/installs/elixir/1.17.3-otp-27/bin/../lib \
-pa /Users/chris/.asdf/installs/elixir/1.17.3-otp-27/bin/../lib/elixir/ebin \
-elixir ansi_enabled true \
-s elixir start_cli \
-extra -e IO.puts("hi")
That’s an eyeful, but it’s mostly because paths are long and messy. Flag by flag:
-
-noshell -
Skips starting the Erlang shell. We want this init flag if we’re working in Elixir and not Erlang. Either we don’t want a shell, or we want IEx.
-
-elixir_root /Users/chris/.asdf/installs/elixir/1.17.3-otp-27/bin/../lib -
-elixir_rootis a user flag. We’re telling theinitto store the path to our installed Elixirlibdir under the key:elixir_root.Spoiler: The
start/2function of theelixirmodule uses this stored value to set paths toebindirectories, where.beamfiles are.
-
-pa /Users/chris/.asdf/installs/elixir/1.17.3-otp-27/bin/../lib/elixir/ebin -
-pais an init flag. Adds the path to the Elixir.beamfiles to Erlang’s code path.
-
-elixir ansi_enabled true -
This is that special
-<application> <parameter> <value>user-flag format. Sets:ansi_enabledtotruein the:elixirOTP application’s config when it starts.
-
-s elixir start_cli -
-sis another init flag. Run functionstart_cli/0of theelixirmodule.This function starts the
elixirOTP application (and starts theloggerapplication) and finally passes our list of stored plain arguments toElixir.Kernel.CLI.main/1, “the API invoked by [the] Elixir boot process” with this line:'Elixir.Kernel.CLI':main(init:get_plain_arguments()).(Good thing we told Erlang where to find Elixir with the
-paflag.)
-
-extra -e IO.puts("hi") -
Each thing after
-extrain anerlexpression is a plain argument, so-eand'IO.puts("hi")'are stored by theinitas such.My
'IO.puts("hi")'came out without its single quotes. I can roll with that; I had to type it in a way that would be parsed unambiguously in the shell, and now it’s in the shape it has to be in for the next thing to parse.On that topic, we can quickly check how the
initstores plain arguments:erl -extra -e 'IO.puts("hi")'Erlang/OTP 27 [erts-15.2.1] [source] [64-bit] [smp:12:12] [ds:12:12:10] [async-threads:1] [jit] Eshell V15.2.1 (press Ctrl+G to abort, type help(). for help) 1> init:get_plain_arguments(). ["-e","IO.puts(\"hi\")"](Plain arguments keep their order in the list, which is important when it comes to parsing and using them.)
Recapping what’s explicit in the erl expression: typing elixir -e 'IO.puts("hi")' into my system shell starts an Erlang VM (BEAM) instance in which the Erlang init process:
- doesn’t start an Erlang shell
-
prepares the Erlang runtime environment, including:
-
setting the path to Elixir
.beamfiles -
storing some
arguments, which in this case are for setting up theelixirOTP application -
storing some
plain_argumentsthat correspond to the arguments I passed to theelixircommand
-
setting the path to Elixir
-
invokes the
start_cli/0function of theelixirmodule
That’s the end of what we can read on the face of the erl expression.
Then, elixir:start_cli/0 configures and starts the elixir and logger OTP applications, yoinks the list of plain arguments from the Erlang init, and passes that to our first Elixir function, Elixir.Kernel.CLI.main/1.
So I want to know what Elixir does with these arguments.
What Elixir does with the plain arguments
Elixir.Kernel.CLI.main/1 starts out with a map of default config options for its own internal use and the incoming argv list provided by elixir:start_cli/0, and surfs parse_argv/2 clauses, knocking arguments out of argv and modifying its config map as they match.
The arguments must include something to run, or there’s no point to any of this. An argument preceded by a flag like "-S" or "-e", or an argument that hasn’t been otherwise recognised, and so is assumed to indicate a file, is identified as a thing to run, and populates the commands list in the config map.
What remains in argv once the parse_argv gauntlet is run replaces the System “command line arguments” list for whatever runs next, and Kernel.CLI.main/1 turns its attention to executing its list of commands.
So that’s what “the API invoked by [the] Elixir boot process” meant. The options you pass to the elixir CLI command, telling it what you want “Elixir” to do, go to the Kernel.CLI Elixir module, which makes it happen.
My request to evaluate the Elixir syntax IO.puts("hi") is executed. The VM prints hi to stdout. I see it in my terminal. Elixir has done what I asked and halts the system.
mix
ELIXIR_CLI_DRY_RUN=1 mix
erl -noshell \
-elixir_root /Users/chris/.asdf/installs/elixir/1.17.3-otp-27/bin/../lib \
-pa /Users/chris/.asdf/installs/elixir/1.17.3-otp-27/bin/../lib/elixir/ebin \
-elixir ansi_enabled true \
-s elixir start_cli \
-extra /Users/chris/.asdf/installs/elixir/1.17.3-otp-27/bin/mix
This is identical to the elixir -e 'IO.puts("hi")' example, but with different plain arguments after the -extra flag.
Elixir.Kernel.CLI will recognise the path to the mix script as the path to a file, read it, and call the function inside: Mix.CLI.main().
By the way: these erl commands aren’t for us
Running erl commands from the shell does an end run around the Elixir installation and the environment setup it takes care of.
It just so happens that I got away with running the simple elixir example by its erl equivalent, but trying the same with mix leaves me missing MIX_HOME and MIX_ARCHIVES environment variables, the initial symptom being that Mix doesn’t know where to find Hex.
iex
ELIXIR_CLI_DRY_RUN=1 iex
erl -noshell \
-elixir_root /Users/chris/.asdf/installs/elixir/1.17.3-otp-27/bin/../lib \
-pa /Users/chris/.asdf/installs/elixir/1.17.3-otp-27/bin/../lib/elixir/ebin \
-elixir ansi_enabled true \
-user elixir \
-extra --no-halt +iex
In the not-IEx commands, -s elixir start_cli started Elixir, but the -s flag doesn’t show up here.
-
-user elixir -
ELUSIVE. But important!
-useris a user flag, and isn’t in the docs, but it’s a special one; the Erlanguser_supmodule looks in the init arguments for the keyuser.The
useris the process that deals with the Erlang VM’s I/O; to interact with IEx, we need input and output to go through it.The best resource I’ve found for this Erlang
userconcept: REPL? A bit more (and less) than that by Fred Hebert. (“If you want to change where the IO takes place, change the user process, and everything gets redirected.”)It looks like passing
-user elixirmeansuser_sup:start_user/3callsapply(elixir, start, []).In turn,
elixir:start/0passesuser_drv:start/2iex:shell/0for itsinitial_shellargument andiex:shell/0, finally, callselixir:start_cli/0, which feeds our plain arguments toKernel.CLIto be acted on.
-
-extra --no-halt +iex -
The plain arguments are
--no-haltand+iex.We can find their significance in the Elixir
Kernel.CLIsource:-
--no-haltresults in ano_halt: trueentry in theconfigmap, which results in the decision not to emitSystem.halt(status)after executingcommands. -
+iexsetsmode: :iexinconfig; this seems to be mainly used to decide how to deal with other flags like-vor--version, and--dbg.
-
Differences from the elixir command:
-
start the
iexapplication afterelixir -
send all VM I/O through
iex -
don’t halt the system once
Kernel.CLIhas processed the plain arguments and taken any actions they ask for
As we know, the end result is that I’m dropped into an IEx shell once all that’s done, and I can ask IEx to run moar Elixir.
What’s running in the VM?
Bare minimum, that is.
Here are the started applications in my bare IEX shell.
iex -e "IO.inspect(Application.started_applications)"
Erlang/OTP 27 [erts-15.2.1] [source] [64-bit] [smp:12:12] [ds:12:12:10] [async-threads:1] [jit]
[
{:logger, ~c"logger", ~c"1.17.3"},
{:iex, ~c"iex", ~c"1.17.3"},
{:elixir, ~c"elixir", ~c"1.17.3"},
{:compiler, ~c"ERTS CXC 138 10", ~c"8.5.4"},
{:stdlib, ~c"ERTS CXC 138 10", ~c"6.2"},
{:kernel, ~c"ERTS CXC 138 10", ~c"10.2.1"}
]
Interactive Elixir (1.17.3) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)>
I haven’t run into the place that starts the compiler application, but I can wave my hands and say it makes sense that we’d need it; I might ask the VM to run something that hasn’t been compiled yet.
I can compare the applications started in a VM that I spun up to run an Elixir function to list the applications started in it:
elixir -e "IO.inspect(Application.started_applications)"
[
{:logger, ~c"logger", ~c"1.17.3"},
{:elixir, ~c"elixir", ~c"1.17.3"},
{:compiler, ~c"ERTS CXC 138 10", ~c"8.5.4"},
{:stdlib, ~c"ERTS CXC 138 10", ~c"6.2"},
{:kernel, ~c"ERTS CXC 138 10", ~c"10.2"}
]
For fun, here’s one just for erl. Just kernel and stdlib in this VM:
erl -eval 'io:format("~p~n", [application:which_applications()]).'
Erlang/OTP 27 [erts-15.2.1] [source] [64-bit] [smp:12:12] [ds:12:12:10] [async-threads:1] [jit]
[{stdlib,"ERTS CXC 138 10","6.2"},{kernel,"ERTS CXC 138 10","10.2.1"}]
Eshell V15.2.1 (press Ctrl+G to abort, type help(). for help)
1>
Putting the pieces together
When we start the BEAM with an iex command, all the VM’s I/O goes through IEx, and IEx implements the interactivity, including taking care of interpretation of Elixir expressions, or compilation of code, as necessary.
All that’s left is to add -S mix phx.server:
ELIXIR_CLI_DRY_RUN=1 iex -S mix phx.server
erl -noshell \
-elixir_root /Users/chris/.asdf/installs/elixir/1.17.3-otp-27/bin/../lib \
-pa /Users/chris/.asdf/installs/elixir/1.17.3-otp-27/bin/../lib/elixir/ebin \
-elixir ansi_enabled true \
-user elixir \
-extra --no-halt +iex -S mix phx.server
No surprises. Just like iex, but with an additional -S mix phx.server after -extra.
Check on the plain arguments:
iex(1)> :init.get_plain_arguments()
[~c"--no-halt", ~c"+iex", ~c"-S", ~c"mix", ~c"phx.server"]
How does Mix get its arguments?
We already talked about how the ["-S", "mix"] part of the arguments list means Elixir.Kernel.CLI will execute the mix script.
And we kinda know that Mix has to see the "phx.server" argument, since that’s the Mix Task we’re trying to run. Let’s just put a tidy bow on how that happens:
As soon as Kernel.CLI.parse_argv/2 matches "-S", it stashes the next argument as a :script into its list of commands to run, and quits looking for things to parse. Everything that’s left in the original arguments list stays in the system command-line arguments list (System.argv/0). In this case, "phx.server" is what’s left.
On the Mix side, the contents of the mix script calls Mix.CLI.main/1, which defaults to getting its argument list from System.argv/0. Voilà: Mix.CLI.main(["phx.server"]).
An analogous thing happens, also via Kernel.CLI, if we run mix phx.server, or elixir -S mix phx.server.
The first thing that Mix.CLI.main/1 does is start the Mix application with Mix.start/0—but I could go on like this forever. Let’s just agree that Mix is now going to run the phx.server task.
Why is it shaped like this?
I now truly believe that iex -S mix phx.server starts my Phoenix dev server on a fresh Erlang VM and drops me into an Elixir shell in that VM.
I’ve had the chance to admire the effort and ingenuity that goes into letting users run programs—no—start up VMs and run programs on them—with simple one-liners.
I still go back and forth between “this is an obvious command that’s really explicit about what it does” and “something about this feels magical and weird.” That -S just sits kinda funny.
If I were in the habit of using elixir -S mix phx.server to run the phx.server Mix task (which works): look, symmetry! iex -S mix phx.server is the same thing but through IEx!
But I don’t do that, because mix phx.server works, thanks to mix being an Elixir script you can also invoke from a system shell. It all makes sense! Not so symmetric, though.
In the end, it’s a balance between elegance and power. I see a lot more elegance in it than I did before.
Trivia: Some history of iex -S mix
Early on, it seems there was some tension between the conciseness of mix iex (possible when mix is a plain shell script) and the flexibility of iex -S mix (possible when mix is an Elixir script); bin/mix went back and forth between shell script and Elixir script in 2012-2013:
- 2012/07/16: “Add mix file executables” (a shell script)
- 2012/07/31: “Make mix an Elixir script” (an Elixir script)
- 2012/11/30: “Fix mix iex” (a shell script again)
-
2012/12/05: J. Valim: “I believe we need to remove support for
mix iexas it needs to beiex -S mix.” (v0.7.2 breaks mix #692; some discussion here.) - 2013/01/20: “Provide iex -S mix instead of mix iex” (an Elixir script again)